@krknet/suitesyncer
v2.0.0
Published
NetSuite Restlet API
Readme
SuiteSyncer
Oracle NetSuite Restlet API Connector
Requirements
- Node.js 22 or newer
@krknet/profiler1.5 or newer as a peer dependency
Usage
const { SuiteSyncer } = require('@krknet/suitesyncer')
const suiteSyncer = new SuiteSyncer({
isSyncEnabled: true,
queue: {
captureException: Sentry.captureException
},
restlet: {
accountID: '000000_SB1',
scriptNr: 1,
deployNr: 1,
consumerID: 'consumerID',
consumerSecret: 'consumerSecret',
tokenID: 'tokenID',
tokenSecret: 'tokenSecret'
},
syncers: [
{
key: 'getTest',
isActive: true,
isAutostarting: false,
interval: 5, // Minutes
runner: async (endpoint, profiler) => {
try {
const result = await endpoint.get({
mode: 'test',
before: Date.now()
})
profiler.succeed(`${result.length} results`)
} catch (err) {
profiler.fail(err.message)
}
}
},
{
key: 'pushTest',
action: async (endpoint, profiler, payload = {}) => {
try {
const answer = await endpoint.post({ mode: 'test', ...payload })
profiler.succeed()
return answer
} catch (err) {
profiler.fail(err.message)
}
}
}
]
})
await suiteSyncer.start()
console.log(await suiteSyncer.call('pushTest', { test: 2 }))
await suiteSyncer.stop()Options
{
isSyncEnabled: false,
queue: { // completely optional
captureException: console.error // Gracefull Error Handling
},
restlet: { // required
accountID: null, // required
scriptNr: 0, // required
deployNr: 0, // required
consumerID: null, // required
consumerSecret: null, // required
tokenID: null, // required
tokenSecret: null, // required
timeout: 60000 // optional, milliseconds
},
syncers: null // folder holding Syncers, or Array of Syncers
}Functions
async start ()
get state () // 'idle' | 'running' | 'stopped'
get isActive () // true only while running
get isSyncEnabled ()
async call (action, payload)
async stop ()Runtime behavior
Lifecycle and shutdown
Create one SuiteSyncer instance, call start() once before submitting actions, and call stop() once during shutdown. When isSyncEnabled is false, start() does not activate either queue and the instance stays idle.
An instance is idle, running, or stopped. isActive is true only while running; use isSyncEnabled to read the configured flag. Calling start() on an instance that is already running or stopped throws — a stopped instance is not restartable. stop() is idempotent.
stop() disables both queues and clears recurring runner intervals as well as pending autostart timers. It does not wait for an operation that is already running: an in-flight call() still settles normally. A call() that was queued but had not started is rejected, so no caller is left waiting on a promise that can never settle. Pending runner jobs remain paused and are not processed after shutdown.
Syncer configuration
syncers is either an array of syncers or a path to a folder holding one module per syncer. A relative path resolves
against the working directory. In a folder, every .js file except index.js becomes a syncer keyed by its filename;
in an array, each entry supplies its own key.
Every syncer is validated while the instance is constructed, so a misconfiguration is reported where it was made rather
than at the moment a runner or a call() would have reached for it. A syncer must carry a key, at least one of
runner and action, an interval that is a positive number of minutes, and an isAutostarting that is either a
boolean or a delay in milliseconds. interval and isAutostarting are coerced as they are checked. A syncer marked
isActive: false is skipped rather than validated, so a half written one can be parked in place. A module that fails to
load is reported with its filename.
Concurrency
Runner jobs are processed serially by one queue, while calls submitted through call() are processed serially by a separate queue. Consequently, at most one runner and one call can execute concurrently.
Response envelopes
A Restlet that answers in an { isSuccess, result } envelope reports its own failures through it, at HTTP 200. When the parsed body carries a boolean isSuccess, the envelope is unwrapped:
// the Restlet answers { isSuccess: true, result: [ ... ] }
const orders = await endpoint.get({ call: 'getSalesOrders' })
// the Restlet answers { isSuccess: false, result: { msg: 'RCRD_DSNT_EXIST: ...' } }
// -> rejects with a RestletError of kind 'netsuite'A body without a boolean isSuccess is returned untouched, so a Restlet that answers with a plain payload is unaffected.
Errors and timeouts
call() rejects when the instance is not running, when the requested action does not exist, when the named syncer has no action, and when a shutdown cancels it before it starts. Action failures reject the returned promise. Runner failures are passed to queue.captureException, which defaults to console.error.
A runner is deduplicated by its syncer key: while one of its jobs is queued or running, the autostart job and any interval tick for that same syncer are ignored rather than piling up behind it.
Every REST failure rejects with a RestletError, so a caller discriminates on kind rather than on the shape of cause:
| kind | Raised when | Also set |
| --- | --- | --- |
| transport | The request never completed | code (ECONNRESET, ENOTFOUND, ...) |
| timeout | The request outlived its timeout | — |
| http | The Restlet answered with a status of 400 or greater | statusCode, body |
| redirect | The Restlet answered with a redirect, which is never followed | statusCode, body |
| payload | The response body was not valid JSON | body |
| netsuite | The Restlet reported a failure | code, function, parameters, body |
Redirects are never followed, so a 3xx is a failure rather than a silently empty answer: NetSuite redirects an unauthenticated request to its login page, which would otherwise arrive as an empty body and read as success.
All of them carry method and url, and the original error or NetSuite error object as cause. A failure raised
inside a runner also carries syncer, the key of the syncer it belongs to, which toSentryContext reads to keep
issues apart. It is null on a failure raised anywhere else. Request headers are deliberately never attached, because they carry the OAuth signature and the token key.
A netsuite failure keeps NetSuite's own server-side stack trace as the error's stack, so the trace points at the SuiteScript rather than at this library. The stack it replaces stays reachable as localStack, which points at the frame that issued the request.
try {
await endpoint.get({ mode: 'test' })
} catch (err) {
if (err.kind === 'timeout' || err.kind === 'transport') retryLater()
else if (err.code === 'RCRD_DSNT_EXIST') skip()
else profiler.fail(`${err.kind}: ${err.message}`)
}The class itself is exported for an instanceof check, and carries the taxonomy as RestletError.KIND so a caller
does not have to spell the strings:
const { SuiteSyncer, RestletError } = require('@krknet/suitesyncer')
if (err instanceof RestletError && err.kind === RestletError.KIND.TIMEOUT) retryLater()A helper that rethrows a failure can fold context into it with annotate(), which toSentryContext reads as extra.
It returns the error, so annotating and rethrowing is one statement:
catch (err) {
throw err.annotate({ fetchMode: 'updated', locationCount: locations.length })
}Each request is aborted after restlet.timeout milliseconds, defaulting to 60000 (one minute). A timeout rejects with a RestletError whose kind is timeout; the underlying TimeoutError remains available as cause. Because both queues run serially, this also bounds how long one stuck request can block everything queued behind it.
Every endpoint method takes an optional second argument to override the timeout for that request alone, which is useful for a long-running saved search or a bulk write that legitimately exceeds the instance default:
await endpoint.get({ mode: 'test' }, { timeout: 5000 }) // tighter than the default
await endpoint.post({ mode: 'bulk' }, { timeout: 300000 }) // looser, for a slow writeThe override applies only to that request; the instance default is unchanged.
