@frelseren/promise-task-maps 0.1.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 ADDED
@@ -0,0 +1,150 @@
1
+ # Promise Task Maps
2
+
3
+ Promise-based task mapping operators inspired by RxJS: `switchMap`, `concatMap`, `mergeMap`, and `exhaustMap`.
4
+
5
+ This package is framework-agnostic and works in browser environments, including LWC.
6
+
7
+ ## Why this library
8
+
9
+ RxJS operators are excellent for stream pipelines, but many codebases only need Promise-based task control:
10
+
11
+ - `switchMap`: keep only the latest task result
12
+ - `concatMap`: queue and execute tasks one-by-one
13
+ - `mergeMap`: run tasks concurrently with an optional limit
14
+ - `exhaustMap`: ignore new tasks while one is in progress
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install @nv/promise-task-maps
20
+ ```
21
+
22
+ ## API
23
+
24
+ ```ts
25
+ type PromiseTask<I, O> = (input: I, context: { signal: AbortSignal; callId: number }) => Promise<O> | O;
26
+
27
+ type TaskRunner<I, O> = {
28
+ execute(input: I): Promise<O | undefined>;
29
+ cancel(reason?: string): void;
30
+ };
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```ts
36
+ import { createSwitchMap } from "@nv/promise-task-maps";
37
+
38
+ const searchUsers = createSwitchMap(async (query: string, { signal }) => {
39
+ const response = await fetch(`/api/users?q=${encodeURIComponent(query)}`, { signal });
40
+ return response.json();
41
+ });
42
+
43
+ // Typing fast in a search box calls execute repeatedly.
44
+ // Older in-flight calls are canceled/ignored; only the latest resolves with data.
45
+ const result = await searchUsers.execute("nic");
46
+ ```
47
+
48
+ ### concatMap example
49
+
50
+ ```ts
51
+ import { createConcatMap } from "@nv/promise-task-maps";
52
+
53
+ const saveDraft = createConcatMap(async (payload: { id: string; body: string }, { signal }) => {
54
+ const response = await fetch(`/api/drafts/${payload.id}`, {
55
+ method: "PUT",
56
+ headers: { "Content-Type": "application/json" },
57
+ body: JSON.stringify({ body: payload.body }),
58
+ signal
59
+ });
60
+ return response.ok;
61
+ });
62
+
63
+ // Calls are queued and executed in order to avoid race conditions.
64
+ await Promise.all([
65
+ saveDraft.execute({ id: "a1", body: "v1" }),
66
+ saveDraft.execute({ id: "a1", body: "v2" }),
67
+ saveDraft.execute({ id: "a1", body: "v3" })
68
+ ]);
69
+ ```
70
+
71
+ ### mergeMap example
72
+
73
+ ```ts
74
+ import { createMergeMap } from "@nv/promise-task-maps";
75
+
76
+ const fetchProfile = createMergeMap(
77
+ async (userId: string, { signal }) => {
78
+ const response = await fetch(`/api/users/${userId}`, { signal });
79
+ return response.json();
80
+ },
81
+ { concurrency: 3 }
82
+ );
83
+
84
+ // Up to 3 requests run in parallel; remaining calls wait in queue.
85
+ const profiles = await Promise.all([
86
+ fetchProfile.execute("u1"),
87
+ fetchProfile.execute("u2"),
88
+ fetchProfile.execute("u3"),
89
+ fetchProfile.execute("u4")
90
+ ]);
91
+ ```
92
+
93
+ ### exhaustMap example
94
+
95
+ ```ts
96
+ import { createExhaustMap } from "@nv/promise-task-maps";
97
+
98
+ const submitOrder = createExhaustMap(async (order: { items: string[] }, { signal }) => {
99
+ const response = await fetch("/api/orders", {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify(order),
103
+ signal
104
+ });
105
+ return response.json();
106
+ });
107
+
108
+ // Double-click protection: second call returns undefined while first is in flight.
109
+ const firstAttempt = submitOrder.execute({ items: ["sku-1"] });
110
+ const secondAttempt = submitOrder.execute({ items: ["sku-1"] });
111
+
112
+ await firstAttempt; // order accepted
113
+ await secondAttempt; // undefined
114
+ ```
115
+
116
+ ## LWC example
117
+
118
+ ```ts
119
+ import { LightningElement } from "lwc";
120
+ import { createSwitchMap } from "@nv/promise-task-maps";
121
+
122
+ export default class UserLookup extends LightningElement {
123
+ users = [];
124
+
125
+ lookup = createSwitchMap(async (query: string, { signal }) => {
126
+ const res = await fetch(`/services/apexrest/users?q=${encodeURIComponent(query)}`, { signal });
127
+ return res.json();
128
+ });
129
+
130
+ async handleInput(event: Event) {
131
+ const target = event.target as HTMLInputElement;
132
+ const data = await this.lookup.execute(target.value);
133
+ if (data) {
134
+ this.users = data;
135
+ }
136
+ }
137
+
138
+ disconnectedCallback() {
139
+ this.lookup.cancel("component disconnected");
140
+ }
141
+ }
142
+ ```
143
+
144
+ ## Development
145
+
146
+ ```bash
147
+ npm install
148
+ npm test
149
+ npm run build
150
+ ```
package/dist/index.cjs ADDED
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ createConcatMap: () => createConcatMap,
24
+ createExhaustMap: () => createExhaustMap,
25
+ createMergeMap: () => createMergeMap,
26
+ createSwitchMap: () => createSwitchMap
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+ var isAbortError = (error) => {
30
+ return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
31
+ };
32
+ var createController = () => new AbortController();
33
+ function createSwitchMap(task) {
34
+ let latestCallId = 0;
35
+ let latestController = createController();
36
+ return {
37
+ async execute(input) {
38
+ latestCallId += 1;
39
+ const callId = latestCallId;
40
+ latestController.abort();
41
+ latestController = createController();
42
+ try {
43
+ const result = await task(input, { signal: latestController.signal, callId });
44
+ return callId === latestCallId ? result : void 0;
45
+ } catch (error) {
46
+ if (callId !== latestCallId || isAbortError(error)) {
47
+ return void 0;
48
+ }
49
+ throw error;
50
+ }
51
+ },
52
+ cancel(reason) {
53
+ latestCallId += 1;
54
+ latestController.abort(reason);
55
+ latestController = createController();
56
+ }
57
+ };
58
+ }
59
+ function createConcatMap(task) {
60
+ let active = false;
61
+ let currentCallId = 0;
62
+ let currentController = createController();
63
+ const queue = [];
64
+ const processQueue = async () => {
65
+ if (active) {
66
+ return;
67
+ }
68
+ const next = queue.shift();
69
+ if (!next) {
70
+ return;
71
+ }
72
+ active = true;
73
+ currentCallId = next.callId;
74
+ currentController = createController();
75
+ try {
76
+ const result = await task(next.input, {
77
+ signal: currentController.signal,
78
+ callId: next.callId
79
+ });
80
+ next.resolve(result);
81
+ } catch (error) {
82
+ if (isAbortError(error)) {
83
+ next.resolve(void 0);
84
+ } else {
85
+ next.reject(error);
86
+ }
87
+ } finally {
88
+ active = false;
89
+ void processQueue();
90
+ }
91
+ };
92
+ return {
93
+ execute(input) {
94
+ const callId = currentCallId + queue.length + 1;
95
+ return new Promise((resolve, reject) => {
96
+ queue.push({ input, resolve, reject, callId });
97
+ void processQueue();
98
+ });
99
+ },
100
+ cancel(reason) {
101
+ currentController.abort(reason);
102
+ while (queue.length > 0) {
103
+ const queued = queue.shift();
104
+ queued?.resolve(void 0);
105
+ }
106
+ }
107
+ };
108
+ }
109
+ function createMergeMap(task, options = {}) {
110
+ const concurrency = options.concurrency ?? Number.POSITIVE_INFINITY;
111
+ if (!Number.isFinite(concurrency) && concurrency !== Number.POSITIVE_INFINITY) {
112
+ throw new Error("mergeMap concurrency must be a finite positive number or Infinity");
113
+ }
114
+ if (concurrency <= 0) {
115
+ throw new Error("mergeMap concurrency must be greater than 0");
116
+ }
117
+ let callId = 0;
118
+ let activeCount = 0;
119
+ let generation = 0;
120
+ const queue = [];
121
+ const activeControllers = /* @__PURE__ */ new Map();
122
+ const pumpQueue = () => {
123
+ while (activeCount < concurrency && queue.length > 0) {
124
+ const next = queue.shift();
125
+ if (!next) {
126
+ break;
127
+ }
128
+ if (next.generation !== generation) {
129
+ next.resolve(void 0);
130
+ continue;
131
+ }
132
+ activeCount += 1;
133
+ const controller = createController();
134
+ activeControllers.set(next.callId, controller);
135
+ void (async () => {
136
+ try {
137
+ const result = await task(next.input, {
138
+ signal: controller.signal,
139
+ callId: next.callId
140
+ });
141
+ next.resolve(result);
142
+ } catch (error) {
143
+ if (isAbortError(error) || next.generation !== generation) {
144
+ next.resolve(void 0);
145
+ } else {
146
+ next.reject(error);
147
+ }
148
+ } finally {
149
+ activeCount -= 1;
150
+ activeControllers.delete(next.callId);
151
+ pumpQueue();
152
+ }
153
+ })();
154
+ }
155
+ };
156
+ return {
157
+ execute(input) {
158
+ callId += 1;
159
+ const thisCallId = callId;
160
+ const thisGeneration = generation;
161
+ return new Promise((resolve, reject) => {
162
+ queue.push({
163
+ input,
164
+ resolve,
165
+ reject,
166
+ callId: thisCallId,
167
+ generation: thisGeneration
168
+ });
169
+ pumpQueue();
170
+ });
171
+ },
172
+ cancel(reason) {
173
+ generation += 1;
174
+ for (const controller of activeControllers.values()) {
175
+ controller.abort(reason);
176
+ }
177
+ while (queue.length > 0) {
178
+ const queued = queue.shift();
179
+ queued?.resolve(void 0);
180
+ }
181
+ }
182
+ };
183
+ }
184
+ function createExhaustMap(task) {
185
+ let active = false;
186
+ let callId = 0;
187
+ let generation = 0;
188
+ let controller = createController();
189
+ return {
190
+ async execute(input) {
191
+ if (active) {
192
+ return void 0;
193
+ }
194
+ callId += 1;
195
+ const thisCallId = callId;
196
+ const thisGeneration = generation;
197
+ active = true;
198
+ controller = createController();
199
+ try {
200
+ const result = await task(input, { signal: controller.signal, callId: thisCallId });
201
+ return thisGeneration === generation ? result : void 0;
202
+ } catch (error) {
203
+ if (isAbortError(error) || thisGeneration !== generation) {
204
+ return void 0;
205
+ }
206
+ throw error;
207
+ } finally {
208
+ active = false;
209
+ }
210
+ },
211
+ cancel(reason) {
212
+ generation += 1;
213
+ controller.abort(reason);
214
+ }
215
+ };
216
+ }
217
+ // Annotate the CommonJS export names for ESM import in node:
218
+ 0 && (module.exports = {
219
+ createConcatMap,
220
+ createExhaustMap,
221
+ createMergeMap,
222
+ createSwitchMap
223
+ });
@@ -0,0 +1,18 @@
1
+ interface TaskContext {
2
+ signal: AbortSignal;
3
+ callId: number;
4
+ }
5
+ type PromiseTask<I, O> = (input: I, context: TaskContext) => Promise<O> | O;
6
+ interface TaskRunner<I, O> {
7
+ execute(input: I): Promise<O | undefined>;
8
+ cancel(reason?: string): void;
9
+ }
10
+ interface MergeMapOptions {
11
+ concurrency?: number;
12
+ }
13
+ declare function createSwitchMap<I, O>(task: PromiseTask<I, O>): TaskRunner<I, O>;
14
+ declare function createConcatMap<I, O>(task: PromiseTask<I, O>): TaskRunner<I, O>;
15
+ declare function createMergeMap<I, O>(task: PromiseTask<I, O>, options?: MergeMapOptions): TaskRunner<I, O>;
16
+ declare function createExhaustMap<I, O>(task: PromiseTask<I, O>): TaskRunner<I, O>;
17
+
18
+ export { type MergeMapOptions, type PromiseTask, type TaskContext, type TaskRunner, createConcatMap, createExhaustMap, createMergeMap, createSwitchMap };
@@ -0,0 +1,18 @@
1
+ interface TaskContext {
2
+ signal: AbortSignal;
3
+ callId: number;
4
+ }
5
+ type PromiseTask<I, O> = (input: I, context: TaskContext) => Promise<O> | O;
6
+ interface TaskRunner<I, O> {
7
+ execute(input: I): Promise<O | undefined>;
8
+ cancel(reason?: string): void;
9
+ }
10
+ interface MergeMapOptions {
11
+ concurrency?: number;
12
+ }
13
+ declare function createSwitchMap<I, O>(task: PromiseTask<I, O>): TaskRunner<I, O>;
14
+ declare function createConcatMap<I, O>(task: PromiseTask<I, O>): TaskRunner<I, O>;
15
+ declare function createMergeMap<I, O>(task: PromiseTask<I, O>, options?: MergeMapOptions): TaskRunner<I, O>;
16
+ declare function createExhaustMap<I, O>(task: PromiseTask<I, O>): TaskRunner<I, O>;
17
+
18
+ export { type MergeMapOptions, type PromiseTask, type TaskContext, type TaskRunner, createConcatMap, createExhaustMap, createMergeMap, createSwitchMap };
package/dist/index.js ADDED
@@ -0,0 +1,195 @@
1
+ // src/index.ts
2
+ var isAbortError = (error) => {
3
+ return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
4
+ };
5
+ var createController = () => new AbortController();
6
+ function createSwitchMap(task) {
7
+ let latestCallId = 0;
8
+ let latestController = createController();
9
+ return {
10
+ async execute(input) {
11
+ latestCallId += 1;
12
+ const callId = latestCallId;
13
+ latestController.abort();
14
+ latestController = createController();
15
+ try {
16
+ const result = await task(input, { signal: latestController.signal, callId });
17
+ return callId === latestCallId ? result : void 0;
18
+ } catch (error) {
19
+ if (callId !== latestCallId || isAbortError(error)) {
20
+ return void 0;
21
+ }
22
+ throw error;
23
+ }
24
+ },
25
+ cancel(reason) {
26
+ latestCallId += 1;
27
+ latestController.abort(reason);
28
+ latestController = createController();
29
+ }
30
+ };
31
+ }
32
+ function createConcatMap(task) {
33
+ let active = false;
34
+ let currentCallId = 0;
35
+ let currentController = createController();
36
+ const queue = [];
37
+ const processQueue = async () => {
38
+ if (active) {
39
+ return;
40
+ }
41
+ const next = queue.shift();
42
+ if (!next) {
43
+ return;
44
+ }
45
+ active = true;
46
+ currentCallId = next.callId;
47
+ currentController = createController();
48
+ try {
49
+ const result = await task(next.input, {
50
+ signal: currentController.signal,
51
+ callId: next.callId
52
+ });
53
+ next.resolve(result);
54
+ } catch (error) {
55
+ if (isAbortError(error)) {
56
+ next.resolve(void 0);
57
+ } else {
58
+ next.reject(error);
59
+ }
60
+ } finally {
61
+ active = false;
62
+ void processQueue();
63
+ }
64
+ };
65
+ return {
66
+ execute(input) {
67
+ const callId = currentCallId + queue.length + 1;
68
+ return new Promise((resolve, reject) => {
69
+ queue.push({ input, resolve, reject, callId });
70
+ void processQueue();
71
+ });
72
+ },
73
+ cancel(reason) {
74
+ currentController.abort(reason);
75
+ while (queue.length > 0) {
76
+ const queued = queue.shift();
77
+ queued?.resolve(void 0);
78
+ }
79
+ }
80
+ };
81
+ }
82
+ function createMergeMap(task, options = {}) {
83
+ const concurrency = options.concurrency ?? Number.POSITIVE_INFINITY;
84
+ if (!Number.isFinite(concurrency) && concurrency !== Number.POSITIVE_INFINITY) {
85
+ throw new Error("mergeMap concurrency must be a finite positive number or Infinity");
86
+ }
87
+ if (concurrency <= 0) {
88
+ throw new Error("mergeMap concurrency must be greater than 0");
89
+ }
90
+ let callId = 0;
91
+ let activeCount = 0;
92
+ let generation = 0;
93
+ const queue = [];
94
+ const activeControllers = /* @__PURE__ */ new Map();
95
+ const pumpQueue = () => {
96
+ while (activeCount < concurrency && queue.length > 0) {
97
+ const next = queue.shift();
98
+ if (!next) {
99
+ break;
100
+ }
101
+ if (next.generation !== generation) {
102
+ next.resolve(void 0);
103
+ continue;
104
+ }
105
+ activeCount += 1;
106
+ const controller = createController();
107
+ activeControllers.set(next.callId, controller);
108
+ void (async () => {
109
+ try {
110
+ const result = await task(next.input, {
111
+ signal: controller.signal,
112
+ callId: next.callId
113
+ });
114
+ next.resolve(result);
115
+ } catch (error) {
116
+ if (isAbortError(error) || next.generation !== generation) {
117
+ next.resolve(void 0);
118
+ } else {
119
+ next.reject(error);
120
+ }
121
+ } finally {
122
+ activeCount -= 1;
123
+ activeControllers.delete(next.callId);
124
+ pumpQueue();
125
+ }
126
+ })();
127
+ }
128
+ };
129
+ return {
130
+ execute(input) {
131
+ callId += 1;
132
+ const thisCallId = callId;
133
+ const thisGeneration = generation;
134
+ return new Promise((resolve, reject) => {
135
+ queue.push({
136
+ input,
137
+ resolve,
138
+ reject,
139
+ callId: thisCallId,
140
+ generation: thisGeneration
141
+ });
142
+ pumpQueue();
143
+ });
144
+ },
145
+ cancel(reason) {
146
+ generation += 1;
147
+ for (const controller of activeControllers.values()) {
148
+ controller.abort(reason);
149
+ }
150
+ while (queue.length > 0) {
151
+ const queued = queue.shift();
152
+ queued?.resolve(void 0);
153
+ }
154
+ }
155
+ };
156
+ }
157
+ function createExhaustMap(task) {
158
+ let active = false;
159
+ let callId = 0;
160
+ let generation = 0;
161
+ let controller = createController();
162
+ return {
163
+ async execute(input) {
164
+ if (active) {
165
+ return void 0;
166
+ }
167
+ callId += 1;
168
+ const thisCallId = callId;
169
+ const thisGeneration = generation;
170
+ active = true;
171
+ controller = createController();
172
+ try {
173
+ const result = await task(input, { signal: controller.signal, callId: thisCallId });
174
+ return thisGeneration === generation ? result : void 0;
175
+ } catch (error) {
176
+ if (isAbortError(error) || thisGeneration !== generation) {
177
+ return void 0;
178
+ }
179
+ throw error;
180
+ } finally {
181
+ active = false;
182
+ }
183
+ },
184
+ cancel(reason) {
185
+ generation += 1;
186
+ controller.abort(reason);
187
+ }
188
+ };
189
+ }
190
+ export {
191
+ createConcatMap,
192
+ createExhaustMap,
193
+ createMergeMap,
194
+ createSwitchMap
195
+ };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@frelseren/promise-task-maps",
3
+ "version": "0.1.0",
4
+ "description": "RxJS-like mapping operators for Promise-based task management",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "sideEffects": false,
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "author": {
14
+ "name": "Nikita Verkhoshintcev",
15
+ "email": "nikita@digitalflask.com",
16
+ "url": "https://digitalflask.com"
17
+ },
18
+ "homepage": "https://digitalflask.com",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/Frelseren/promise-task-maps.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/Frelseren/promise-task-maps/issues"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js",
33
+ "require": "./dist/index.cjs"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsup src/index.ts --dts --format esm,cjs --clean",
41
+ "prepublishOnly": "npm test && npm run build",
42
+ "test": "vitest run",
43
+ "test:watch": "vitest"
44
+ },
45
+ "keywords": [
46
+ "promise",
47
+ "task",
48
+ "switchMap",
49
+ "concatMap",
50
+ "mergeMap",
51
+ "exhaustMap",
52
+ "lwc"
53
+ ],
54
+ "license": "MIT",
55
+ "devDependencies": {
56
+ "tsup": "^8.3.0",
57
+ "typescript": "^5.7.0",
58
+ "vitest": "^2.1.8"
59
+ }
60
+ }