netsuite-sdk 0.1.0 → 0.1.2

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.
Files changed (2) hide show
  1. package/README.md +409 -91
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,18 +1,44 @@
1
1
  # netsuite-sdk
2
2
 
3
- TypeScript-first NetSuite REST API client with first-class SuiteQL support, OAuth 1.0a authentication, automatic retries, and a fluent query builder.
4
-
5
- ## Features
6
-
7
- - **SuiteQL** — `query()`, `queryOne()`, and `queryPages()` with auto-pagination
8
- - **Fluent query builder** type-safe SQL construction with value escaping
9
- - **REST Record API** — full CRUD: `get`, `list`, `create`, `update`, `replace`, `delete`, `upsert`
10
- - **RESTlet support** — auto-built URLs from script/deploy IDs
11
- - **OAuth 1.0a (TBA)** — HMAC-SHA256 signing, fresh nonce on each retry
12
- - **Automatic retries** — exponential backoff with jitter for 5xx/timeout/network errors
13
- - **Middleware** — extensible request/response pipeline
14
- - **TypeScript-first** — strict mode, full type inference, generics everywhere
15
- - **Dual format** — ships both CJS and ESM with `.d.ts` declarations
3
+ [![npm version](https://img.shields.io/npm/v/netsuite-sdk.svg)](https://www.npmjs.com/package/netsuite-sdk)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.5-blue.svg)](https://www.typescriptlang.org/)
6
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](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: 'your-consumer-key',
31
- consumerSecret: 'your-consumer-secret',
32
- tokenKey: 'your-token-key',
33
- tokenSecret: 'your-token-secret',
34
- realm: 'your-account-id',
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: 'your-account-id', // e.g., "1234567" or "1234567_SB1" for sandbox
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
- ### Raw SQL queries
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
- console.log(result.items); // Customer[]
57
- console.log(result.totalResults); // total count from NetSuite
58
- console.log(result.pagesFetched); // number of API calls made
59
- console.log(result.duration); // total ms
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
- ### Get a single row
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
- // Returns Customer | null
115
+
116
+ if (customer) {
117
+ console.log(customer.companyname);
118
+ }
69
119
  ```
70
120
 
71
- ### Stream large result sets
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 processBatch(page); // page is Customer[]
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) // false → 'F' automatically
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
- Builder methods: `select()`, `from()`, `join()`, `leftJoin()`, `rightJoin()`, `where()`, `whereEquals()`, `whereNotEquals()`, `whereIn()`, `whereNull()`, `whereNotNull()`, `whereBetween()`, `whereLike()`, `groupBy()`, `having()`, `orderBy()`.
103
-
104
- String values are automatically escaped (single quotes doubled).
105
-
106
- ### SuiteQL Options
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, // rows per page (default: 1000, max: 1000)
111
- offset: 0, // starting offset (default: 0)
112
- maxRows: 10000, // cap total rows fetched (default: Infinity)
113
- timeout: 60000, // override timeout for this query
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 list = await client.records.list('invoice', {
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, { email: 'new@acme.com' });
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, { companyname: 'New Name' });
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-001', {
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
- const result = await client.restlets.call(
158
- { script: '100', deploy: '1', params: { action: 'search' } },
159
- { method: 'POST', body: { type: 'customer' } }
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 custom endpoints:
268
+ Escape hatch for any NetSuite REST endpoint not covered by the higher-level clients:
166
269
 
167
270
  ```ts
168
- const res = await client.get<MyType>('https://1234567.suitetalk.api.netsuite.com/custom/endpoint');
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
- // Logging middleware
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} (${res.duration}ms)`);
308
+ console.log(`← ${res.status} in ${res.duration}ms`);
183
309
  return res;
184
310
  });
185
311
 
186
- // Custom header injection
312
+ // Custom headers
187
313
  client.use(async (ctx, next) => {
188
- ctx.headers['X-Custom-Header'] = 'value';
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', 999);
381
+ await client.records.get('customer', 999999);
200
382
  } catch (error) {
201
- if (error instanceof NetSuiteError) {
202
- error.status; // HTTP status code
203
- error.code; // NetSuite error code (e.g., "RCRD_DSNT_EXIST")
204
- error.message; // Human-readable message
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 failed
207
- error.requestMethod; // HTTP method that failed
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', // or "1234567_SB1" for sandbox
226
- timeout: 30000, // request timeout in ms (default: 30000)
227
- maxRetries: 3, // retry attempts for transient errors (default: 3)
228
- retryDelay: 1000, // initial retry delay in ms (default: 1000)
229
- defaultHeaders: { // headers added to every request
230
- 'X-App-Name': 'my-app',
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
- logger: console, // any object with debug/info/warn/error methods
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
- ResponseCache,
241
- createCacheKey,
242
- RateLimiter,
243
- validateConfig,
244
- formatNetSuiteDate,
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "netsuite-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "TypeScript-first NetSuite REST API client with first-class SuiteQL support",
5
5
  "author": "David Turton",
6
6
  "repository": {