netsuite-sdk 0.1.0 → 0.1.1
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 +409 -91
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,18 +1,44 @@
|
|
|
1
1
|
# netsuite-sdk
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
3
|
+
[](https://www.npmjs.com/package/netsuite-sdk)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://www.typescriptlang.org/)
|
|
6
|
+
[](https://nodejs.org/)
|
|
7
|
+
|
|
8
|
+
A TypeScript-first NetSuite REST API client with first-class SuiteQL support, OAuth 1.0a (TBA) authentication, automatic pagination, retries with exponential backoff, and a fluent query builder.
|
|
9
|
+
|
|
10
|
+
## Why netsuite-sdk?
|
|
11
|
+
|
|
12
|
+
- **SuiteQL-first** — Auto-pagination, streaming with `AsyncGenerator`, and a fluent query builder that escapes values and maps booleans to `'T'`/`'F'`
|
|
13
|
+
- **Full REST Record API** — `get`, `list`, `create`, `update`, `replace`, `delete`, `upsert` with typed generics
|
|
14
|
+
- **RESTlet support** — Call custom RESTlets by script/deploy ID
|
|
15
|
+
- **Resilient** — Exponential backoff with jitter, fresh OAuth nonce on each retry, configurable retry strategies
|
|
16
|
+
- **Middleware pipeline** — Composable request/response hooks for logging, caching, rate limiting, or custom headers
|
|
17
|
+
- **TypeScript-first** — Strict mode, full type inference, generics on every API method
|
|
18
|
+
- **Dual format** — Ships both CJS and ESM with `.d.ts` declarations and source maps
|
|
19
|
+
- **Zero config** — Sensible defaults for timeout (30s), retries (3), page size (1000)
|
|
20
|
+
|
|
21
|
+
## Table of Contents
|
|
22
|
+
|
|
23
|
+
- [Install](#install)
|
|
24
|
+
- [Quick Start](#quick-start)
|
|
25
|
+
- [SuiteQL](#suiteql)
|
|
26
|
+
- [Query all rows](#query-all-rows)
|
|
27
|
+
- [Query a single row](#query-a-single-row)
|
|
28
|
+
- [Stream pages](#stream-pages)
|
|
29
|
+
- [Query builder](#query-builder)
|
|
30
|
+
- [Pagination options](#pagination-options)
|
|
31
|
+
- [REST Record API](#rest-record-api)
|
|
32
|
+
- [RESTlets](#restlets)
|
|
33
|
+
- [Raw HTTP](#raw-http)
|
|
34
|
+
- [Middleware](#middleware)
|
|
35
|
+
- [Error Handling](#error-handling)
|
|
36
|
+
- [Configuration](#configuration)
|
|
37
|
+
- [Utilities](#utilities)
|
|
38
|
+
- [Architecture](#architecture)
|
|
39
|
+
- [Requirements](#requirements)
|
|
40
|
+
- [Contributing](#contributing)
|
|
41
|
+
- [License](#license)
|
|
16
42
|
|
|
17
43
|
## Install
|
|
18
44
|
|
|
@@ -20,6 +46,14 @@ TypeScript-first NetSuite REST API client with first-class SuiteQL support, OAut
|
|
|
20
46
|
npm install netsuite-sdk
|
|
21
47
|
```
|
|
22
48
|
|
|
49
|
+
```bash
|
|
50
|
+
yarn add netsuite-sdk
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pnpm add netsuite-sdk
|
|
55
|
+
```
|
|
56
|
+
|
|
23
57
|
## Quick Start
|
|
24
58
|
|
|
25
59
|
```ts
|
|
@@ -27,19 +61,30 @@ import { NetSuiteClient } from 'netsuite-sdk';
|
|
|
27
61
|
|
|
28
62
|
const client = new NetSuiteClient({
|
|
29
63
|
auth: {
|
|
30
|
-
consumerKey:
|
|
31
|
-
consumerSecret:
|
|
32
|
-
tokenKey:
|
|
33
|
-
tokenSecret:
|
|
34
|
-
realm:
|
|
64
|
+
consumerKey: process.env.NS_CONSUMER_KEY!,
|
|
65
|
+
consumerSecret: process.env.NS_CONSUMER_SECRET!,
|
|
66
|
+
tokenKey: process.env.NS_TOKEN_KEY!,
|
|
67
|
+
tokenSecret: process.env.NS_TOKEN_SECRET!,
|
|
68
|
+
realm: process.env.NS_ACCOUNT_ID!,
|
|
35
69
|
},
|
|
36
|
-
accountId:
|
|
70
|
+
accountId: process.env.NS_ACCOUNT_ID!, // e.g. "1234567" or "1234567_SB1"
|
|
37
71
|
});
|
|
72
|
+
|
|
73
|
+
// Run a SuiteQL query
|
|
74
|
+
const { items } = await client.suiteql.query<{ id: string; companyname: string }>(
|
|
75
|
+
'SELECT id, companyname FROM customer WHERE ROWNUM <= 10'
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
console.log(items);
|
|
38
79
|
```
|
|
39
80
|
|
|
81
|
+
> **Sandbox accounts**: Use the `"1234567_SB1"` format for `accountId`. Underscores are automatically converted to hyphens for URL construction (e.g. `1234567-sb1.suitetalk.api.netsuite.com`).
|
|
82
|
+
|
|
40
83
|
## SuiteQL
|
|
41
84
|
|
|
42
|
-
###
|
|
85
|
+
### Query all rows
|
|
86
|
+
|
|
87
|
+
Auto-paginates across multiple API calls and returns every matching row:
|
|
43
88
|
|
|
44
89
|
```ts
|
|
45
90
|
interface Customer {
|
|
@@ -48,39 +93,52 @@ interface Customer {
|
|
|
48
93
|
email: string;
|
|
49
94
|
}
|
|
50
95
|
|
|
51
|
-
// Auto-paginates and returns all rows
|
|
52
96
|
const result = await client.suiteql.query<Customer>(
|
|
53
97
|
"SELECT id, companyname, email FROM customer WHERE isinactive = 'F'"
|
|
54
98
|
);
|
|
55
99
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
100
|
+
result.items; // Customer[] — all rows across all pages
|
|
101
|
+
result.totalResults; // total count reported by NetSuite
|
|
102
|
+
result.pagesFetched; // number of API round-trips
|
|
103
|
+
result.hasMore; // false when all matching rows were fetched
|
|
104
|
+
result.duration; // total elapsed time in ms
|
|
60
105
|
```
|
|
61
106
|
|
|
62
|
-
###
|
|
107
|
+
### Query a single row
|
|
108
|
+
|
|
109
|
+
Returns the first row, or `null` if no rows match:
|
|
63
110
|
|
|
64
111
|
```ts
|
|
65
112
|
const customer = await client.suiteql.queryOne<Customer>(
|
|
66
113
|
'SELECT id, companyname FROM customer WHERE id = 123'
|
|
67
114
|
);
|
|
68
|
-
|
|
115
|
+
|
|
116
|
+
if (customer) {
|
|
117
|
+
console.log(customer.companyname);
|
|
118
|
+
}
|
|
69
119
|
```
|
|
70
120
|
|
|
71
|
-
### Stream
|
|
121
|
+
### Stream pages
|
|
122
|
+
|
|
123
|
+
Use `queryPages()` for large result sets. It returns an `AsyncGenerator` that yields one page at a time, keeping memory usage constant:
|
|
72
124
|
|
|
73
125
|
```ts
|
|
126
|
+
let processed = 0;
|
|
127
|
+
|
|
74
128
|
for await (const page of client.suiteql.queryPages<Customer>(
|
|
75
129
|
'SELECT id, companyname FROM customer',
|
|
76
130
|
{ pageSize: 500 }
|
|
77
131
|
)) {
|
|
78
|
-
await
|
|
132
|
+
await insertBatch(page); // page is Customer[]
|
|
133
|
+
processed += page.length;
|
|
134
|
+
console.log(`Processed ${processed} rows...`);
|
|
79
135
|
}
|
|
80
136
|
```
|
|
81
137
|
|
|
82
138
|
### Query builder
|
|
83
139
|
|
|
140
|
+
Build SuiteQL queries programmatically with automatic value escaping:
|
|
141
|
+
|
|
84
142
|
```ts
|
|
85
143
|
import { suiteql } from 'netsuite-sdk';
|
|
86
144
|
|
|
@@ -88,9 +146,10 @@ const sql = suiteql()
|
|
|
88
146
|
.select('c.id', 'c.companyname', 'COUNT(t.id) AS order_count')
|
|
89
147
|
.from('customer', 'c')
|
|
90
148
|
.leftJoin('transaction t', 'c.id = t.entity')
|
|
91
|
-
.whereEquals('c.isinactive', false)
|
|
149
|
+
.whereEquals('c.isinactive', false) // false → 'F' automatically
|
|
92
150
|
.whereNotNull('c.email')
|
|
93
151
|
.whereIn('c.subsidiary', [1, 2, 3])
|
|
152
|
+
.whereBetween('t.trandate', '2024-01-01', '2024-12-31')
|
|
94
153
|
.groupBy('c.id', 'c.companyname')
|
|
95
154
|
.having('COUNT(t.id) > 0')
|
|
96
155
|
.orderBy('order_count', 'DESC')
|
|
@@ -99,160 +158,419 @@ const sql = suiteql()
|
|
|
99
158
|
const result = await client.suiteql.query(sql);
|
|
100
159
|
```
|
|
101
160
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
161
|
+
**Available builder methods:**
|
|
162
|
+
|
|
163
|
+
| Method | Example |
|
|
164
|
+
|--------|---------|
|
|
165
|
+
| `select(...columns)` | `.select('id', 'name')` |
|
|
166
|
+
| `from(table, alias?)` | `.from('customer', 'c')` |
|
|
167
|
+
| `join(table, condition)` | `.join('transaction t', 'c.id = t.entity')` |
|
|
168
|
+
| `leftJoin(table, condition)` | `.leftJoin('address a', 'c.id = a.entity')` |
|
|
169
|
+
| `rightJoin(table, condition)` | `.rightJoin('subsidiary s', 'c.subsidiary = s.id')` |
|
|
170
|
+
| `where(condition)` | `.where('ROWNUM <= 100')` |
|
|
171
|
+
| `whereEquals(col, val)` | `.whereEquals('isinactive', false)` |
|
|
172
|
+
| `whereNotEquals(col, val)` | `.whereNotEquals('status', 'closed')` |
|
|
173
|
+
| `whereIn(col, values)` | `.whereIn('id', [1, 2, 3])` |
|
|
174
|
+
| `whereNull(col)` | `.whereNull('email')` |
|
|
175
|
+
| `whereNotNull(col)` | `.whereNotNull('email')` |
|
|
176
|
+
| `whereBetween(col, start, end)` | `.whereBetween('total', 100, 500)` |
|
|
177
|
+
| `whereLike(col, pattern)` | `.whereLike('name', '%acme%')` |
|
|
178
|
+
| `groupBy(...columns)` | `.groupBy('subsidiary')` |
|
|
179
|
+
| `having(condition)` | `.having('COUNT(*) > 5')` |
|
|
180
|
+
| `orderBy(col, direction?)` | `.orderBy('name', 'ASC')` |
|
|
181
|
+
|
|
182
|
+
**Value escaping rules:**
|
|
183
|
+
|
|
184
|
+
- **Numbers** — passed through as-is: `100`
|
|
185
|
+
- **Booleans** — mapped to NetSuite convention: `true` → `'T'`, `false` → `'F'`
|
|
186
|
+
- **Strings** — wrapped in single quotes with internal quotes doubled: `O'Brien` → `'O''Brien'`
|
|
187
|
+
|
|
188
|
+
### Pagination options
|
|
107
189
|
|
|
108
190
|
```ts
|
|
109
191
|
await client.suiteql.query(sql, {
|
|
110
|
-
pageSize: 500,
|
|
111
|
-
offset: 0,
|
|
112
|
-
maxRows: 10000,
|
|
113
|
-
timeout: 60000,
|
|
192
|
+
pageSize: 500, // rows per API call (default: 1000, max: 1000)
|
|
193
|
+
offset: 0, // starting offset (default: 0)
|
|
194
|
+
maxRows: 10000, // cap total rows fetched (default: Infinity)
|
|
195
|
+
timeout: 60000, // override timeout for this query (ms)
|
|
114
196
|
});
|
|
115
197
|
```
|
|
116
198
|
|
|
117
199
|
## REST Record API
|
|
118
200
|
|
|
201
|
+
Full CRUD operations on any NetSuite record type:
|
|
202
|
+
|
|
119
203
|
```ts
|
|
120
|
-
// Get a record
|
|
204
|
+
// Get a single record
|
|
121
205
|
const customer = await client.records.get('customer', 123, {
|
|
122
|
-
fields: ['companyname', 'email'],
|
|
206
|
+
fields: ['companyname', 'email', 'phone'],
|
|
123
207
|
expandSubResources: true,
|
|
124
208
|
});
|
|
209
|
+
console.log(customer.data.companyname);
|
|
125
210
|
|
|
126
211
|
// List records
|
|
127
|
-
const
|
|
212
|
+
const invoices = await client.records.list('invoice', {
|
|
128
213
|
limit: 25,
|
|
129
214
|
offset: 0,
|
|
130
|
-
fields: ['tranid', 'total'],
|
|
215
|
+
fields: ['tranid', 'total', 'status'],
|
|
216
|
+
query: { status: 'open' },
|
|
131
217
|
});
|
|
132
218
|
|
|
133
|
-
// Create
|
|
134
|
-
await client.records.create('customer', {
|
|
219
|
+
// Create a record
|
|
220
|
+
const created = await client.records.create('customer', {
|
|
135
221
|
companyname: 'Acme Corp',
|
|
136
222
|
email: 'info@acme.com',
|
|
223
|
+
subsidiary: { id: 1 },
|
|
137
224
|
});
|
|
138
225
|
|
|
139
|
-
// Update (PATCH — partial)
|
|
140
|
-
await client.records.update('customer', 123, {
|
|
226
|
+
// Update (PATCH — partial update, only sends changed fields)
|
|
227
|
+
await client.records.update('customer', 123, {
|
|
228
|
+
email: 'new@acme.com',
|
|
229
|
+
});
|
|
141
230
|
|
|
142
|
-
// Replace (PUT — full)
|
|
143
|
-
await client.records.replace('customer', 123, {
|
|
231
|
+
// Replace (PUT — full replace)
|
|
232
|
+
await client.records.replace('customer', 123, {
|
|
233
|
+
companyname: 'New Name',
|
|
234
|
+
email: 'new@acme.com',
|
|
235
|
+
});
|
|
144
236
|
|
|
145
237
|
// Delete
|
|
146
238
|
await client.records.delete('customer', 123);
|
|
147
239
|
|
|
148
240
|
// Upsert via external ID
|
|
149
|
-
await client.records.upsert('customer', 'externalId', 'CRM-
|
|
241
|
+
await client.records.upsert('customer', 'externalId', 'CRM-12345', {
|
|
150
242
|
companyname: 'Upserted Corp',
|
|
243
|
+
email: 'upserted@corp.com',
|
|
151
244
|
});
|
|
152
245
|
```
|
|
153
246
|
|
|
247
|
+
**Common record types:** `customer`, `invoice`, `salesorder`, `purchaseorder`, `vendor`, `employee`, `contact`, `item`, `transaction`, `journalentry`, `creditmemo`, `vendorbill`, and [any other NetSuite record type](https://system.netsuite.com/help/helpcenter/en_US/APIs/REST_API_Browser/record/v1/2024.2/index.html).
|
|
248
|
+
|
|
154
249
|
## RESTlets
|
|
155
250
|
|
|
251
|
+
Call custom server-side scripts deployed as RESTlets:
|
|
252
|
+
|
|
156
253
|
```ts
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
{
|
|
254
|
+
// GET request to a RESTlet
|
|
255
|
+
const result = await client.restlets.call<{ customers: Customer[] }>(
|
|
256
|
+
{ script: '100', deploy: '1', params: { action: 'search', limit: '50' } },
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// POST request with a body
|
|
260
|
+
const created = await client.restlets.call<{ success: boolean }>(
|
|
261
|
+
{ script: '100', deploy: '1' },
|
|
262
|
+
{ method: 'POST', body: { type: 'customer', data: { name: 'Acme' } } },
|
|
160
263
|
);
|
|
161
264
|
```
|
|
162
265
|
|
|
163
266
|
## Raw HTTP
|
|
164
267
|
|
|
165
|
-
Escape hatch for
|
|
268
|
+
Escape hatch for any NetSuite REST endpoint not covered by the higher-level clients:
|
|
166
269
|
|
|
167
270
|
```ts
|
|
168
|
-
|
|
271
|
+
// All methods are available with full typing
|
|
272
|
+
const res = await client.get<MyType>(url);
|
|
169
273
|
const res = await client.post<MyType>(url, body);
|
|
170
274
|
const res = await client.put<MyType>(url, body);
|
|
171
275
|
const res = await client.patch<MyType>(url, body);
|
|
172
276
|
const res = await client.delete(url);
|
|
277
|
+
|
|
278
|
+
// Or use the generic request method
|
|
279
|
+
const res = await client.request<MyType>(url, {
|
|
280
|
+
method: 'POST',
|
|
281
|
+
body: { key: 'value' },
|
|
282
|
+
headers: { 'X-Custom': 'header' },
|
|
283
|
+
timeout: 60000,
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Every response includes:
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
res.data; // T — parsed response body
|
|
291
|
+
res.status; // number — HTTP status code
|
|
292
|
+
res.headers; // Record<string, string>
|
|
293
|
+
res.duration; // number — round-trip time in ms
|
|
173
294
|
```
|
|
174
295
|
|
|
175
296
|
## Middleware
|
|
176
297
|
|
|
298
|
+
The middleware pipeline lets you intercept every request and response. Middleware functions receive a `RequestContext` and a `next()` function that calls the next middleware (or the actual HTTP request):
|
|
299
|
+
|
|
177
300
|
```ts
|
|
178
|
-
|
|
301
|
+
import type { Middleware } from 'netsuite-sdk';
|
|
302
|
+
|
|
303
|
+
// Logging
|
|
179
304
|
client.use(async (ctx, next) => {
|
|
305
|
+
const start = Date.now();
|
|
180
306
|
console.log(`→ ${ctx.method} ${ctx.url}`);
|
|
181
307
|
const res = await next();
|
|
182
|
-
console.log(`← ${res.status}
|
|
308
|
+
console.log(`← ${res.status} in ${res.duration}ms`);
|
|
183
309
|
return res;
|
|
184
310
|
});
|
|
185
311
|
|
|
186
|
-
// Custom
|
|
312
|
+
// Custom headers
|
|
187
313
|
client.use(async (ctx, next) => {
|
|
188
|
-
ctx.headers['X-
|
|
314
|
+
ctx.headers['X-Request-Id'] = crypto.randomUUID();
|
|
189
315
|
return next();
|
|
190
316
|
});
|
|
191
317
|
```
|
|
192
318
|
|
|
319
|
+
Middleware is called in the order added. Chain `.use()` calls:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
client
|
|
323
|
+
.use(loggingMiddleware)
|
|
324
|
+
.use(cachingMiddleware)
|
|
325
|
+
.use(rateLimitMiddleware);
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Caching middleware example
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
import { ResponseCache, createCacheKey } from 'netsuite-sdk';
|
|
332
|
+
|
|
333
|
+
const cache = new ResponseCache();
|
|
334
|
+
|
|
335
|
+
client.use(async (ctx, next) => {
|
|
336
|
+
if (ctx.method !== 'GET') return next();
|
|
337
|
+
|
|
338
|
+
const key = createCacheKey(ctx.url, ctx.method);
|
|
339
|
+
const cached = cache.get(key);
|
|
340
|
+
if (cached) return cached;
|
|
341
|
+
|
|
342
|
+
const res = await next();
|
|
343
|
+
cache.set(key, res, 300); // cache for 5 minutes
|
|
344
|
+
return res;
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Rate limiting middleware example
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
import { RateLimiter } from 'netsuite-sdk';
|
|
352
|
+
|
|
353
|
+
const limiter = new RateLimiter(10, 1000); // 10 requests per second
|
|
354
|
+
|
|
355
|
+
client.use(async (ctx, next) => {
|
|
356
|
+
const wait = limiter.getTimeUntilNextSlot();
|
|
357
|
+
if (wait > 0) await new Promise(r => setTimeout(r, wait));
|
|
358
|
+
limiter.recordRequest();
|
|
359
|
+
return next();
|
|
360
|
+
});
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**`RequestContext`** properties available in middleware:
|
|
364
|
+
|
|
365
|
+
| Property | Type | Description |
|
|
366
|
+
|----------|------|-------------|
|
|
367
|
+
| `url` | `string` | Full request URL |
|
|
368
|
+
| `method` | `HttpMethod` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` |
|
|
369
|
+
| `headers` | `Record<string, string>` | Mutable headers — modify before calling `next()` |
|
|
370
|
+
| `body` | `unknown` | Request body (for POST/PUT/PATCH) |
|
|
371
|
+
| `metadata` | `Record<string, unknown>` | Arbitrary data shared between middleware |
|
|
372
|
+
|
|
193
373
|
## Error Handling
|
|
194
374
|
|
|
375
|
+
All non-2xx responses throw a `NetSuiteError` with structured fields for programmatic handling:
|
|
376
|
+
|
|
195
377
|
```ts
|
|
196
378
|
import { NetSuiteError } from 'netsuite-sdk';
|
|
197
379
|
|
|
198
380
|
try {
|
|
199
|
-
await client.records.get('customer',
|
|
381
|
+
await client.records.get('customer', 999999);
|
|
200
382
|
} catch (error) {
|
|
201
|
-
if (error
|
|
202
|
-
error.
|
|
203
|
-
|
|
204
|
-
|
|
383
|
+
if (NetSuiteError.isNetSuiteError(error)) {
|
|
384
|
+
console.error(`[${error.code}] ${error.message}`);
|
|
385
|
+
// → [RCRD_DSNT_EXIST] That record does not exist.
|
|
386
|
+
|
|
387
|
+
error.status; // 404
|
|
388
|
+
error.code; // "RCRD_DSNT_EXIST"
|
|
389
|
+
error.message; // "That record does not exist."
|
|
205
390
|
error.details; // Full NetSuite error response body
|
|
206
|
-
error.requestUrl; // URL that
|
|
207
|
-
error.requestMethod; //
|
|
208
|
-
error.isRetryable; // true for 5xx, timeout, network errors
|
|
209
|
-
error.isAuthError; // true for 401, 403
|
|
391
|
+
error.requestUrl; // URL that was called
|
|
392
|
+
error.requestMethod; // "GET"
|
|
393
|
+
error.isRetryable; // false (only true for 5xx, timeout, network errors)
|
|
394
|
+
error.isAuthError; // false (only true for 401, 403)
|
|
210
395
|
}
|
|
211
396
|
}
|
|
212
397
|
```
|
|
213
398
|
|
|
399
|
+
**Retry behavior:**
|
|
400
|
+
|
|
401
|
+
| Scenario | Retried? | Details |
|
|
402
|
+
|----------|----------|---------|
|
|
403
|
+
| 5xx server errors | Yes | Retried up to `maxRetries` times |
|
|
404
|
+
| Timeouts | Yes | Treated as transient |
|
|
405
|
+
| Network errors | Yes | Connection failures, DNS issues, etc. |
|
|
406
|
+
| 4xx client errors | No | Bad request, not found, validation errors |
|
|
407
|
+
| 401 / 403 auth errors | No | Thrown immediately |
|
|
408
|
+
|
|
409
|
+
- OAuth is **re-signed on each retry** with a fresh nonce and timestamp
|
|
410
|
+
- Backoff uses **exponential delay** (1s → 2s → 4s → ...) with **jitter** (+/- 25%) to prevent thundering herd
|
|
411
|
+
- Max delay is capped at 30 seconds
|
|
412
|
+
|
|
214
413
|
## Configuration
|
|
215
414
|
|
|
216
415
|
```ts
|
|
217
416
|
const client = new NetSuiteClient({
|
|
417
|
+
// Required
|
|
218
418
|
auth: {
|
|
219
|
-
consumerKey: '...',
|
|
220
|
-
consumerSecret: '...',
|
|
221
|
-
tokenKey: '...',
|
|
222
|
-
tokenSecret: '...',
|
|
223
|
-
realm: '...',
|
|
419
|
+
consumerKey: '...', // OAuth Consumer Key / Client ID
|
|
420
|
+
consumerSecret: '...', // OAuth Consumer Secret / Client Secret
|
|
421
|
+
tokenKey: '...', // OAuth Token ID
|
|
422
|
+
tokenSecret: '...', // OAuth Token Secret
|
|
423
|
+
realm: '...', // Account ID (same as accountId)
|
|
224
424
|
},
|
|
225
|
-
accountId: '1234567', //
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
425
|
+
accountId: '1234567', // NetSuite account ID
|
|
426
|
+
|
|
427
|
+
// Optional (shown with defaults)
|
|
428
|
+
timeout: 30000, // Request timeout in ms
|
|
429
|
+
maxRetries: 3, // Max retry attempts for transient errors
|
|
430
|
+
retryDelay: 1000, // Initial retry delay in ms (doubles each attempt)
|
|
431
|
+
defaultHeaders: {}, // Headers added to every request
|
|
432
|
+
logger: undefined, // Logger with debug/info/warn/error methods
|
|
433
|
+
});
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
### Environment variables
|
|
437
|
+
|
|
438
|
+
Store credentials securely using environment variables:
|
|
439
|
+
|
|
440
|
+
```bash
|
|
441
|
+
# .env
|
|
442
|
+
NS_ACCOUNT_ID=1234567
|
|
443
|
+
NS_CONSUMER_KEY=abc123...
|
|
444
|
+
NS_CONSUMER_SECRET=def456...
|
|
445
|
+
NS_TOKEN_KEY=ghi789...
|
|
446
|
+
NS_TOKEN_SECRET=jkl012...
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
```ts
|
|
450
|
+
import 'dotenv/config';
|
|
451
|
+
import { NetSuiteClient } from 'netsuite-sdk';
|
|
452
|
+
|
|
453
|
+
const client = new NetSuiteClient({
|
|
454
|
+
auth: {
|
|
455
|
+
consumerKey: process.env.NS_CONSUMER_KEY!,
|
|
456
|
+
consumerSecret: process.env.NS_CONSUMER_SECRET!,
|
|
457
|
+
tokenKey: process.env.NS_TOKEN_KEY!,
|
|
458
|
+
tokenSecret: process.env.NS_TOKEN_SECRET!,
|
|
459
|
+
realm: process.env.NS_ACCOUNT_ID!,
|
|
231
460
|
},
|
|
232
|
-
|
|
461
|
+
accountId: process.env.NS_ACCOUNT_ID!,
|
|
462
|
+
});
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### Logger integration
|
|
466
|
+
|
|
467
|
+
Pass any logger that implements `debug`, `info`, `warn`, `error`:
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
// Works with pino, winston, console, or any compatible logger
|
|
471
|
+
import pino from 'pino';
|
|
472
|
+
|
|
473
|
+
const client = new NetSuiteClient({
|
|
474
|
+
// ...auth config
|
|
475
|
+
logger: pino(), // Automatically logs requests, responses, and retries
|
|
233
476
|
});
|
|
234
477
|
```
|
|
235
478
|
|
|
236
479
|
## Utilities
|
|
237
480
|
|
|
481
|
+
The SDK exports several standalone utilities you can use independently:
|
|
482
|
+
|
|
483
|
+
### ResponseCache
|
|
484
|
+
|
|
485
|
+
TTL-based in-memory cache:
|
|
486
|
+
|
|
487
|
+
```ts
|
|
488
|
+
import { ResponseCache, createCacheKey } from 'netsuite-sdk';
|
|
489
|
+
|
|
490
|
+
const cache = new ResponseCache();
|
|
491
|
+
cache.set('key', data, 300); // cache for 300 seconds
|
|
492
|
+
const hit = cache.get<MyType>('key'); // MyType | null
|
|
493
|
+
cache.delete('key');
|
|
494
|
+
cache.clear();
|
|
495
|
+
cache.size; // number of entries
|
|
496
|
+
|
|
497
|
+
// Generate keys from request params
|
|
498
|
+
const key = createCacheKey(url, 'GET', { limit: 10 });
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
### RateLimiter
|
|
502
|
+
|
|
503
|
+
Sliding-window rate limiter:
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
import { RateLimiter } from 'netsuite-sdk';
|
|
507
|
+
|
|
508
|
+
const limiter = new RateLimiter(100, 60_000); // 100 requests per 60s
|
|
509
|
+
|
|
510
|
+
limiter.canMakeRequest(); // boolean — check before making a request
|
|
511
|
+
limiter.recordRequest(); // track a request
|
|
512
|
+
limiter.getRemainingRequests(); // slots left in current window
|
|
513
|
+
limiter.getTimeUntilNextSlot(); // ms until a slot opens (0 if available)
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
### Other utilities
|
|
517
|
+
|
|
238
518
|
```ts
|
|
239
519
|
import {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
parseNetSuiteDate,
|
|
246
|
-
parseNetSuiteError,
|
|
247
|
-
normalizeAccountId,
|
|
520
|
+
validateConfig, // Validate NetSuiteConfig, returns string[] of error messages
|
|
521
|
+
formatNetSuiteDate, // Date → NetSuite date string
|
|
522
|
+
parseNetSuiteDate, // NetSuite date string → Date
|
|
523
|
+
parseNetSuiteError, // Parse raw error response bodies
|
|
524
|
+
normalizeAccountId, // "1234567_SB1" → "1234567-sb1"
|
|
248
525
|
} from 'netsuite-sdk';
|
|
249
526
|
```
|
|
250
527
|
|
|
528
|
+
## Architecture
|
|
529
|
+
|
|
530
|
+
```
|
|
531
|
+
NetSuiteClient (facade)
|
|
532
|
+
├── SuiteQLClient → POST /services/rest/query/v1/suiteql
|
|
533
|
+
├── RecordClient → /services/rest/record/v1/{type}
|
|
534
|
+
├── RestletClient → /app/site/hosting/restlet.nl
|
|
535
|
+
└── HttpTransport
|
|
536
|
+
├── OAuth 1.0a signing (HMAC-SHA256, fresh nonce per request)
|
|
537
|
+
├── Middleware pipeline (composable request/response hooks)
|
|
538
|
+
└── Retry engine (exponential backoff + jitter)
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
**Key design decisions:**
|
|
542
|
+
|
|
543
|
+
- **OAuth re-signing on retry** — Each retry attempt generates a fresh nonce and timestamp. Many other NetSuite libraries sign once and reuse stale auth headers, causing retries to fail with 401.
|
|
544
|
+
- **All non-2xx throw** — No silent 4xx swallowing. Every non-success response is a `NetSuiteError` with structured properties for programmatic handling.
|
|
545
|
+
- **Library-agnostic middleware** — The `RequestContext` / `ResponseContext` types are decoupled from axios internals, so middleware you write won't break if the transport layer changes in a future version.
|
|
546
|
+
- **Auto-pagination** — SuiteQL `query()` handles offset tracking and page assembly internally. You get back a single array of all results.
|
|
547
|
+
|
|
251
548
|
## Requirements
|
|
252
549
|
|
|
253
|
-
- Node.js >= 20
|
|
254
|
-
- NetSuite account with Token-Based Authentication (TBA) enabled
|
|
550
|
+
- **Node.js** >= 20
|
|
551
|
+
- A **NetSuite account** with [Token-Based Authentication (TBA)](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_4247226131.html) enabled
|
|
552
|
+
- An **integration record** in NetSuite with consumer key/secret
|
|
553
|
+
- A **token** (token key/secret) created for a user/role with appropriate permissions
|
|
554
|
+
|
|
555
|
+
### Setting up TBA in NetSuite
|
|
556
|
+
|
|
557
|
+
1. **Enable TBA**: Setup → Company → Enable Features → SuiteCloud → Manage Authentication → Token-Based Authentication
|
|
558
|
+
2. **Create an Integration**: Setup → Integration → Manage Integrations → New → enable Token-Based Authentication, set callback URL, copy consumer key/secret
|
|
559
|
+
3. **Create a Token**: Setup → Users/Roles → Access Tokens → New → select the integration, user, and role, copy token ID/secret
|
|
560
|
+
|
|
561
|
+
## Contributing
|
|
562
|
+
|
|
563
|
+
```bash
|
|
564
|
+
git clone https://github.com/dturton/netsuite-sdk.git
|
|
565
|
+
cd netsuite-sdk
|
|
566
|
+
npm install
|
|
567
|
+
|
|
568
|
+
npm test # run all 113 tests
|
|
569
|
+
npm run build # build CJS + ESM + .d.ts
|
|
570
|
+
npm run typecheck # type-check without emitting
|
|
571
|
+
npm run dev # watch mode (rebuilds on change)
|
|
572
|
+
```
|
|
255
573
|
|
|
256
574
|
## License
|
|
257
575
|
|
|
258
|
-
MIT
|
|
576
|
+
[MIT](LICENSE)
|