@amerilux/netsuite-api 0.2.1 → 0.5.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.
Files changed (38) hide show
  1. package/README.md +89 -11
  2. package/dist/client/apiClient.js +2 -2
  3. package/dist/client/index.d.ts +1 -1
  4. package/dist/index.d.ts +143 -1
  5. package/dist/server/apiError.d.ts +2 -0
  6. package/dist/server/apiError.js +4 -0
  7. package/dist/server/defineJob.d.ts +95 -0
  8. package/dist/server/defineJob.js +150 -0
  9. package/dist/server/endpoint.js +1 -1
  10. package/dist/server/index.d.ts +8 -3
  11. package/dist/server/index.js +5 -2
  12. package/dist/server/jobRuns.d.ts +65 -0
  13. package/dist/server/jobRuns.js +292 -0
  14. package/dist/server/suiteletClient.js +2 -1
  15. package/dist/testing/N/task.d.ts +14 -0
  16. package/dist/testing/N/task.js +4 -1
  17. package/dist/testing/index.d.ts +3 -0
  18. package/dist/testing/index.js +2 -0
  19. package/dist/testing/jobs.d.ts +50 -0
  20. package/dist/testing/jobs.js +90 -0
  21. package/dist-tooling/cli/main.js +6 -3
  22. package/dist-tooling/config.d.ts +45 -4
  23. package/dist-tooling/config.js +117 -3
  24. package/dist-tooling/controllerReader.d.ts +24 -2
  25. package/dist-tooling/controllerReader.js +7 -6
  26. package/dist-tooling/emit.d.ts +44 -2
  27. package/dist-tooling/emit.js +132 -7
  28. package/dist-tooling/generate.d.ts +10 -1
  29. package/dist-tooling/generate.js +166 -28
  30. package/dist-tooling/index.d.ts +7 -4
  31. package/dist-tooling/index.js +3 -1
  32. package/dist-tooling/jobReader.d.ts +53 -0
  33. package/dist-tooling/jobReader.js +260 -0
  34. package/dist-tooling/typesFileReader.d.ts +28 -9
  35. package/dist-tooling/typesFileReader.js +73 -41
  36. package/dist-tooling/wireTypes.d.ts +16 -0
  37. package/dist-tooling/wireTypes.js +50 -0
  38. package/package.json +1 -1
package/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  The API layer for a NetSuite single-page app. The app's server side is SuiteScript; its browser side is a bundle served by a Suitelet. This package holds what sits between them, so a project writes controllers and services and nothing else:
4
4
 
5
- - **`@amerilux/netsuite-api/server`**: declare a controller's endpoints and the script that serves them, expose them as a Restlet or a Suitelet, reject a call with an `ApiError`, call another Suitelet controller from server code, and find a File Cabinet file by name.
5
+ - **`@amerilux/netsuite-api/server`**: declare a controller's endpoints and the script that serves them, expose them as a Restlet or a Suitelet, reject a call with an `ApiError`, call another Suitelet controller from server code, write a Map/Reduce job as stages and start one, and find a File Cabinet file by name.
6
6
  - **`@amerilux/netsuite-api/client`**: a typed browser client per controller, built from the endpoint types.
7
7
  - **`@amerilux/netsuite-api/testing`**: stubs for the `N/*` modules and the vitest wiring that routes imports to them.
8
- - **`netsuite-api generate`**: reads the controllers and writes the client's whole view of the backend, one module per controller and an index re-exporting them, plus the server-side map of scripts. The client never imports from the server tree.
9
- - **`@amerilux/netsuite-api`** (the root): the wire itself. The envelope, the endpoint types, `ScriptDeclaration`, `ScriptRef`.
8
+ - **`netsuite-api generate`**: reads the controllers and the jobs and writes the client's whole view of the backend, one module each and an index re-exporting them, plus the server-side map of scripts and jobs. The client never imports from the server tree.
9
+ - **`@amerilux/netsuite-api`** (the root): the wire itself. The envelope, the endpoint types, `ScriptDeclaration`, `ScriptRef`, `JobRef`, `JobRun`.
10
10
 
11
11
  The layout it assumes is the one `create-netsuite-project` scaffolds: `api/` (SuiteScript) and `client/` (React) as workspaces.
12
12
 
@@ -45,7 +45,7 @@ export const post = defineRestlet({
45
45
  }, customerEndpoints);
46
46
  ```
47
47
 
48
- Every call is a POST whose JSON body carries the request plus an `endpoint` property naming the endpoint. The handler answers with data, or throws an `ApiError` for a status the caller should see; anything else is a 500 with the details logged. A Suitelet controller is the same file with `defineSuitelet` and `onRequest` instead, and `browser: false` in the declaration when only server code calls it.
48
+ Every call is a POST whose JSON body carries the request plus an `endpoint` property naming the endpoint. The handler answers with data, or throws an `ApiError` for a status the caller should see, with a `details` object the caller can act on (a per-field validation map, the offending id) that travels in the envelope; anything else is a 500 whose cause is logged and never sent. A Suitelet controller is the same file with `defineSuitelet` and `onRequest` instead, and `browser: false` in the declaration when only server code calls it.
49
49
 
50
50
  The declaration is the controller's own statement of the script it is deployed as. It creates nothing: the controller builds, tests and bundles before any script record exists. The ids are how a client reaches the controller once it is deployed, so set them to whatever the record and deployment are called in NetSuite and in the SDF object; the generator wires every client to them.
51
51
 
@@ -53,7 +53,7 @@ The generator reads the file as source, so a few things are rules rather than co
53
53
 
54
54
  - Handlers are written inline with their parameter and return types annotated. A reference to a service function carries no types the generator can read.
55
55
  - Every type in the file is a wire shape and is exported.
56
- - A type is imported only from the inlined files (the generated entity types, by default), the carried modules (the package's server entry, for `RawResponse`) or another controller. A service's return type is never used as a DTO by reference.
56
+ - A type is imported only from the inlined files (by default the generated entity types and the services: what a service returns is the domain type a wire shape is built from), the carried modules (the package's server entry, for `RawResponse`) or another controller. A service's function is never referenced by the handler in place of an annotation.
57
57
  - The script declaration is an object literal with literal ids, its `name` is the file name without `Controller`, and the entry point export and the `@NScriptType` header agree with the define function.
58
58
  - Script ids are unique across controllers, and no wire shape is named `Endpoints`. A shape's name carries no controller prefix: each controller's generated module is its own namespace.
59
59
 
@@ -67,7 +67,7 @@ The generator reads the file as source, so a few things are rules rather than co
67
67
  // client/src/api/customer.gen.ts
68
68
  import { createApiClient } from '@amerilux/netsuite-api/client';
69
69
 
70
- // Entity types from api/src/types/models.gen.ts, copied so this module stands on its own.
70
+ // Types from api/src/types/models.gen.ts, copied so this module stands on its own.
71
71
 
72
72
  export interface Customer {
73
73
  id: number;
@@ -91,7 +91,9 @@ export type Endpoints = {
91
91
  export const api = createApiClient<Endpoints>({ kind: 'restlet', scriptId: 'customscript_app_customer', deployId: 'customdeploy_app_customer' });
92
92
  ```
93
93
 
94
- A type imported from another controller becomes an import of that controller's module. A type built on something the entity file imports itself (`CustomerCreate`, `CustomerPatch`: the repository package's input types) is an error, because the client could not carry it; write the wire shape out in the controller instead. A generated module no controller owns any more is deleted on the next run.
94
+ A type imported from a service is copied the same way, with the entity types it is built on following it in from the models file. A type imported from another controller becomes an import of that controller's module. A type built on something no inlined file declares (`CustomerCreate`, `CustomerPatch`: the repository package's input types; a type a service imports from a package) is an error, because the client could not carry it; write the wire shape out in the controller instead. So is a type reached through a renamed import (`import type { Employee as EmployeeRecord }`) in a service: import it under its own name. A generated module no controller owns any more is deleted on the next run.
95
+
96
+ JSON carries no dates. A `Date` in a response shape (an entity's `tranDate`) arrives in the browser as an ISO 8601 string, so the generated module writes `string` wherever the shape says `Date`; the page formats it. A `Date` in a request shape is an error, because the handler would receive a string where its annotation promises a Date: take a string and parse it in the handler.
95
97
 
96
98
  **`client/src/api/index.gen.ts`**, the client's view of the backend: every controller's module re-exported under the controller's name.
97
99
 
@@ -113,16 +115,17 @@ A hook imports `{ customer }` from it, calls `customer.api.search({ search: 'acm
113
115
  ```json
114
116
  {
115
117
  "controllers": "api/src/controllers",
118
+ "jobs": "api/src/jobs",
116
119
  "outDir": "client/src/api",
117
120
  "scriptsOutFile": "api/src/scripts.gen.ts",
118
121
  "clientModule": "@amerilux/netsuite-api/client",
119
122
  "wireModule": "@amerilux/netsuite-api",
120
123
  "typeImports": { "@amerilux/netsuite-api/server": "@amerilux/netsuite-api/client" },
121
- "inlineTypes": { "../types/models.gen": "api/src/types/models.gen.ts" }
124
+ "inlineTypes": { "../types/models.gen": "api/src/types/models.gen.ts", "../services/*": "api/src/services/*.ts" }
122
125
  }
123
126
  ```
124
127
 
125
- Paths are relative to the config file. `outDir` holds the controller modules and the index, and nothing else. `inlineTypes` maps a specifier as written in a controller to the type-only file whose declarations are copied into the module of every controller importing from it. `typeImports` maps a specifier to the one the client resolves, for a type that stays an import (the package's server entry maps to its client entry so `RawResponse` carries over). A type imported from any other module is an error.
128
+ Paths are relative to the config file. `outDir` holds the controller and job modules and their indexes, and nothing else. A project with jobs adds a `jobRuns` block naming the run record it deployed; see **Jobs**. `inlineTypes` maps a specifier as written in a controller to the file whose type declarations are copied into the module of every controller importing from it; a key with one `*` stands for a file name and the `*` in its file takes that name, so `../services/*` covers every service. Only the type declarations of a file are read, so a service's functions are skipped; a type in one inlined file that refers to a type imported from another (a service's summary type built on an entity type) brings that type along, the import resolved through the same map as written from the same folder depth. `typeImports` maps a specifier to the one the client resolves, for a type that stays an import (the package's server entry maps to its client entry so `RawResponse` carries over). A type imported from any other module is an error.
126
129
 
127
130
  ## The client at runtime
128
131
 
@@ -134,7 +137,7 @@ import { configureApiClient } from '@amerilux/netsuite-api/client';
134
137
  if (import.meta.env.DEV) configureApiClient({ basePaths: { restlet: '/api/restlet', suitelet: '/api/suitelet' } });
135
138
  ```
136
139
 
137
- A failed call rejects with an `ApiClientError` carrying the envelope's status and message; a call that got no answer at all carries `NO_RESPONSE_STATUS` (0). Before it rejects, the failure goes to the handler `configureApiClient` was given, with the script, the endpoint and the request, so the app reports every failure in one place and a hook carries no error handling of its own:
140
+ A failed call rejects with an `ApiClientError` carrying the envelope's status, message and `details` (whatever the handler gave its `ApiError`, for a form to show per field); a call that got no answer at all carries `NO_RESPONSE_STATUS` (0). Before it rejects, the failure goes to the handler `configureApiClient` was given, with the script, the endpoint and the request, so the app reports every failure in one place and a hook carries no error handling of its own:
138
141
 
139
142
  ```ts
140
143
  configureApiClient({ onError: (error, { endpoint }) => showBanner(`${endpoint}: ${error.message}`) });
@@ -190,9 +193,84 @@ const userRolesApi = createSuiteletClient<UserRolesEndpoints>(scripts.userRoles)
190
193
  export const listRolesForEmployee = (employeeId: number) => userRolesApi.byEmployee({ employeeId }).roles;
191
194
  ```
192
195
 
196
+ ## Jobs
197
+
198
+ A job is a Map/Reduce script written as stages. What the wrapper adds is the run: a Map/Reduce answers nothing and cannot be waited on, so every run is a row in a record of the application's own, and that row is what server code and the browser talk about.
199
+
200
+ ```ts
201
+ /**
202
+ * @NApiVersion 2.1
203
+ * @NScriptType MapReduceScript
204
+ */
205
+ import { defineJob } from '@amerilux/netsuite-api/server';
206
+ import { jobRuns } from '../scripts.gen';
207
+ import { closeOrder, listStaleOrders, type StaleOrder } from '../services/staleOrderService';
208
+
209
+ export interface CloseStaleRequest { olderThanDays: number }
210
+ export interface CloseStaleResult { closed: number }
211
+
212
+ export const { getInputData, map, summarize } = defineJob({
213
+ name: 'closeStaleOrders',
214
+ scriptId: 'customscript_app_close_stale_mr',
215
+ deployments: ['customdeploy_app_close_stale_mr', 'customdeploy_app_close_stale_mr_2'],
216
+ runParameter: 'custscript_app_close_stale_run',
217
+ parameters: { batchSize: { id: 'custscript_app_close_stale_batch', type: 'integer' } },
218
+ runs: jobRuns,
219
+ }, {
220
+ getInputData: (input: CloseStaleRequest): StaleOrder[] => listStaleOrders(input.olderThanDays),
221
+ map: (order: StaleOrder, job): void => {
222
+ if (closeOrder(order.id)) job.write(String(order.id), order.id);
223
+ },
224
+ summarize: (summary): CloseStaleResult => ({ closed: summary.output.length }),
225
+ });
226
+ ```
227
+
228
+ The first parameter of `getInputData` is the run's input and the return type of `summarize` is its result: the generator reads the shapes from those two annotations, the way it reads an endpoint's request and response. The values carried between stages are JSON, so each stage annotates what it expects (`values: number[]` on a reduce stage, `summary: JobSummary<Total>` on summarize) and the wrapper hands them back that way. Export the stages the job has, and `summarize` always: the run is closed there. A stage exported without being declared throws when NetSuite calls it, rather than quietly passing values through.
229
+
230
+ Plenty of jobs answer nothing, because the records they write are the point. Such a job declares no `summarize` and still exports it, and the wrapper closes the run for it; one that wants a last word without a result (a notification when the run ends) declares `summarize` with a `void` return. Either way the run's `Result` is `null`, and a page watches `status`, the progress fields and `errors` instead of a result.
231
+
232
+ Reading a run asks the task about progress as well. `stagePercentComplete` is `getPercentageCompleted()`, which NetSuite documents as the percentage complete of the **stage being processed**, so it counts to 100 once per stage; `itemsProcessed` and `itemsTotal` come from that stage's `getTotal*Count()` and `getPending*Count()` pair and only go up. The record keeps the percent (100 once a run ends) but never the counts, so the counts are null for a run that has ended or whose task id NetSuite has purged — by then the run has the result, which the record did keep.
233
+
234
+ The store belongs in a repository, because it writes a record and submits a task. Keep it job-agnostic: whatever decides a run should start (a service, in the layout the template scaffolds) passes the job and the input.
235
+
236
+ ```ts
237
+ // api/src/repositories/jobRunRepository.ts
238
+ import { createJobRunStore } from '@amerilux/netsuite-api/server';
239
+ import type { JobRef, JobRun } from '@amerilux/netsuite-api/server';
240
+ import { jobRuns } from '../scripts.gen';
241
+
242
+ const jobRunStore = createJobRunStore(jobRuns);
243
+
244
+ export const startJobRun = (job: JobRef, input: unknown): string => jobRunStore.start(job, input);
245
+ export const findJobRun = (runId: string): JobRun | null => jobRunStore.read(runId);
246
+
247
+ // api/src/services/ordersService.ts
248
+ export const startClosingOldOrders = (olderThanDays: number) => startJobRun(jobs.closeOldOrders, { olderThanDays } satisfies CloseOldOrdersRequest);
249
+ ```
250
+
251
+ `start` writes the run, then submits the task to the first deployment that takes it; NetSuite runs one instance of a deployment at a time, so the list in the declaration is how many runs can overlap. When they are all running it throws `ApiError.conflict` (409) and removes the run it had written, because nothing started. A controller endpoint hands the run id to the browser, which polls another endpoint for `read`.
252
+
253
+ `read` is the reason a dead run does not look like a working one: it takes status, stage and progress from `N/task.checkStatus` as well as the record, so a task NetSuite gave up on is `failed`, and a task that finished without writing a result is `failed` too. `findExpired(days)` and `remove` are what a cleanup job runs on a schedule; run records are not meant to be permanent.
254
+
255
+ `findRuns({ job, startedBy, unfinishedOnly, limit })` answers the matching runs newest first, in one query and without loading a record: it is how a page finds the run it lost track of, because the run knows who started it even after a browser has forgotten. Its rows carry what the record says, not what the task says, so read a run by id before believing one is still working — and they carry no progress at all, because only the task has any.
256
+
257
+ ### The run record
258
+
259
+ The shape is this package's and the ids are the application's, because the record carries the application's prefix. `netsuite-api.config.json` names them and the generator writes them into the scripts map as `jobRuns`:
260
+
261
+ ```json
262
+ "jobRuns": {
263
+ "recordType": "customrecord_app_job_run",
264
+ "fieldPrefix": "custrecord_app_jr",
265
+ "extraFields": { "customerId": { "id": "custrecord_app_jr_customer", "type": "integer" } }
266
+ }
267
+ ```
268
+
269
+ Each field's id is the prefix plus a short suffix (`_job`, `_status`, `_stage`, `_percent`, `_input`, `_result`, `_errors`, `_task`, `_deploy`, `_by`, `_start`, `_end`); `fields` overrides any one of them for a record whose field is named differently. `extraFields` are the fields the application added: `start` sets them (`{ extra: { customerId } }`) and a run reports them back under `extra`, typed.
270
+
193
271
  ## Tests
194
272
 
195
- `N/*` modules exist only inside NetSuite. The `testing` entry ships a stub per module, each export a `vi.fn()`, and the vitest wiring:
273
+ `N/*` modules exist only inside NetSuite. The `testing` entry ships a stub per module, each export a `vi.fn()`, the Map/Reduce contexts a job's stages are called with (`mapContextFor`, `reduceContextFor`, `summarizeContextFor`, each capturing what the stage wrote), and the vitest wiring:
196
274
 
197
275
  ```ts
198
276
  import { inlinedPackagesForNetsuiteStubs, netsuiteModuleStubAliases } from '@amerilux/netsuite-api/testing';
@@ -80,7 +80,7 @@ export function callEndpoint(scriptRef, endpointName, request = {}, options = {}
80
80
  throw new ApiClientError(response.status, `Unexpected response from ${scriptRef.scriptId} (${response.status})`, text.slice(0, 500));
81
81
  }
82
82
  if (envelope.error !== null || envelope.status >= 400) {
83
- throw new ApiClientError(envelope.status, (_a = envelope.error) !== null && _a !== void 0 ? _a : `Request failed (${envelope.status})`);
83
+ throw new ApiClientError(envelope.status, (_a = envelope.error) !== null && _a !== void 0 ? _a : `Request failed (${envelope.status})`, envelope.details);
84
84
  }
85
85
  return envelope.data;
86
86
  });
@@ -99,7 +99,7 @@ export function callRawEndpoint(scriptRef, endpointName, request = {}, options =
99
99
  const text = await response.text();
100
100
  const envelope = parseEnvelope(text);
101
101
  if (envelope && (envelope.error !== null || envelope.status >= 400))
102
- throw new ApiClientError(envelope.status, (_b = envelope.error) !== null && _b !== void 0 ? _b : `Request failed (${envelope.status})`);
102
+ throw new ApiClientError(envelope.status, (_b = envelope.error) !== null && _b !== void 0 ? _b : `Request failed (${envelope.status})`, envelope.details);
103
103
  if (!response.ok)
104
104
  throw new ApiClientError(response.status, `Unexpected response from ${scriptRef.scriptId} (${response.status})`, text.slice(0, 500));
105
105
  throw new ApiClientError(response.status, `${scriptRef.scriptId}.${endpointName} answered JSON where a document was expected.`, text.slice(0, 500));
@@ -5,4 +5,4 @@
5
5
  export { ApiClientError, NETSUITE_API_BASE_PATHS, NO_RESPONSE_STATUS, buildApiUrl, callEndpoint, callRawEndpoint, configureApiClient, createApiClient } from './apiClient.js';
6
6
  export type { ApiBasePaths, ApiCallContext, ApiCallOptions, ApiClient, ApiClientConfiguration, ApiClientOptions, ApiErrorHandler, ClientResponse } from './apiClient.js';
7
7
  export { ENDPOINT_PARAMETER } from '../index.js';
8
- export type { ApiEnvelope, ApiErrorBody, Endpoint, Endpoints, EndpointRequest, EndpointResponse, RawResponse, ScriptDeclaration, ScriptKind, ScriptRef } from '../index.js';
8
+ export type { ApiEnvelope, ApiErrorBody, Endpoint, Endpoints, EndpointRequest, EndpointResponse, JobRun, JobRunError, JobRunListEntry, JobRunQuery, JobRunStage, JobRunStatus, RawResponse, ScriptDeclaration, ScriptKind, ScriptRef, } from '../index.js';
package/dist/index.d.ts CHANGED
@@ -2,11 +2,16 @@
2
2
  * The wire: what a controller answers with, how a call names its endpoint, and how a script is
3
3
  * reached. Both sides import this module; nothing in it touches NetSuite or the browser.
4
4
  */
5
- /** Every controller answers with this envelope; `data` is null whenever `error` is set. */
5
+ /**
6
+ * Every controller answers with this envelope; `data` is null whenever `error` is set. `details` is
7
+ * whatever the handler gave its ApiError (a per-field validation map, the offending id), sent so the
8
+ * caller can act on it; a 500 carries none, its cause stays in the log.
9
+ */
6
10
  export interface ApiEnvelope<TData> {
7
11
  status: number;
8
12
  error: string | null;
9
13
  data: TData | null;
14
+ details?: unknown;
10
15
  }
11
16
  /** The body of a failed call, as thrown by the client's ApiClientError. */
12
17
  export interface ApiErrorBody {
@@ -74,3 +79,140 @@ export interface ScriptRef {
74
79
  deployId: string;
75
80
  browser?: boolean;
76
81
  }
82
+ /**
83
+ * Jobs: the Map/Reduce side of the wire. A job answers nothing, so its run is the thing both sides
84
+ * talk about: server code starts one and gets a run id, and the browser asks what that run is doing.
85
+ * A run lives in a record of the application's own (`customrecord_<prefix>_job_run`, whose ids the
86
+ * generator reads from netsuite-api.config.json), so a job that dies before it writes anything is
87
+ * still a run that failed, not a page waiting forever.
88
+ */
89
+ /** How NetSuite stores a script parameter or a run field, and the type the stages and the page see. */
90
+ export type NetsuiteValueType = 'text' | 'integer' | 'decimal' | 'checkbox' | 'date' | 'select';
91
+ /** One script parameter of a job: its id in NetSuite and what NetSuite stores in it. */
92
+ export interface JobParameterDeclaration {
93
+ id: string;
94
+ type: NetsuiteValueType;
95
+ }
96
+ /** The value of one parameter as the stages see it. A date arrives as the ISO string, never a Date: a stage is not the wire, but a run record is read by both sides. */
97
+ export type JobParameterValue<TType extends NetsuiteValueType> = TType extends 'integer' | 'decimal' ? number : TType extends 'checkbox' ? boolean : string;
98
+ /** Every parameter a job declares, by the name the stages use. */
99
+ export type JobParameterValues<TParameters extends Record<string, JobParameterDeclaration>> = {
100
+ readonly [TName in keyof TParameters]: JobParameterValue<TParameters[TName]['type']>;
101
+ };
102
+ /** Where a run is: NetSuite's own stage names, as the run record and the page speak them. */
103
+ export type JobRunStage = 'input' | 'map' | 'shuffle' | 'reduce' | 'summarize';
104
+ /**
105
+ * A run's state. `pending` is submitted but not started, `running` is anything between, and
106
+ * `complete` means summarize wrote the result. `failed` is either a stage that threw or a task
107
+ * NetSuite gave up on, which is why a run is read through checkStatus as well as its record.
108
+ */
109
+ export type JobRunStatus = 'pending' | 'running' | 'complete' | 'failed';
110
+ /** One thing that went wrong in a run: a stage's own throw, or a key NetSuite could not finish. */
111
+ export interface JobRunError {
112
+ stage: JobRunStage;
113
+ /** The key the map or reduce stage was working on, when the failure belongs to one. */
114
+ key?: string;
115
+ message: string;
116
+ }
117
+ /**
118
+ * A run as anyone asking about it sees it: the record's own fields refined by what NetSuite says
119
+ * about the task. Every time is an ISO string, so this shape crosses to the browser unchanged.
120
+ */
121
+ export interface JobRun<TResult = unknown, TExtra extends Record<string, unknown> = Record<string, never>> {
122
+ /** The run record's internal id: what startJob answers and a page polls with. */
123
+ id: string;
124
+ /** The job's name, as its declaration gives it. */
125
+ job: string;
126
+ status: JobRunStatus;
127
+ /** The stage the task is in, or null before it starts and after it ends. */
128
+ stage: JobRunStage | null;
129
+ /**
130
+ * How far the stage being processed has got, 0 to 100, as NetSuite reports it. It counts up inside a
131
+ * stage and starts again at the next one, so it is progress rather than a fraction of the whole run;
132
+ * a finished run reads 100. For something to put next to a progress bar, prefer the item counts.
133
+ */
134
+ stagePercentComplete: number;
135
+ /** Rows the stage being processed has finished, or null when the task can no longer say. */
136
+ itemsProcessed: number | null;
137
+ /** Rows the stage being processed was given, or null when the task can no longer say: a run that has ended, or an id NetSuite has purged. */
138
+ itemsTotal: number | null;
139
+ /** The employee who started it, or null for a scheduled run. */
140
+ startedBy: number | null;
141
+ startedAt: string | null;
142
+ finishedAt: string | null;
143
+ /** The NetSuite task id, kept so a run can be asked about after the fact. */
144
+ taskId: string | null;
145
+ /** What summarize returned, once it has. */
146
+ result: TResult | null;
147
+ errors: JobRunError[];
148
+ /** The fields this application added to the run record, as netsuite-api.config.json declares them. */
149
+ extra: TExtra;
150
+ }
151
+ /**
152
+ * A run as a list shows it: the fields a query can read, so finding a run costs one query rather than a
153
+ * record load each. It carries no input, result or errors — a page finds a run here and then asks about
154
+ * it by id, which is also the only reading that consults the task, so a `running` row in a list is
155
+ * "last we knew", not a promise. Progress is not here either, for the same reason: only the task has it.
156
+ */
157
+ export interface JobRunListEntry {
158
+ id: string;
159
+ job: string;
160
+ status: JobRunStatus;
161
+ stage: JobRunStage | null;
162
+ startedBy: number | null;
163
+ }
164
+ /** Which runs to list. Every field narrows; the newest are answered first. */
165
+ export interface JobRunQuery {
166
+ job?: string;
167
+ /** The employee who started them: how a page finds its own caller's runs again. */
168
+ startedBy?: number;
169
+ /** Only runs that have not ended, for "is one of these already going?". */
170
+ unfinishedOnly?: boolean;
171
+ /** How many, newest first. Defaults to 10, capped at 100. */
172
+ limit?: number;
173
+ }
174
+ /** One deployed job as server code starts it: `scripts.<job>` in the generated scripts map. */
175
+ export interface JobRef {
176
+ kind: 'mapreduce';
177
+ /** The job's name, as its declaration gives it and the run record records it. */
178
+ name: string;
179
+ scriptId: string;
180
+ /**
181
+ * Every deployment the job may run on, in the order they are tried. NetSuite runs one instance of
182
+ * a deployment at a time, so this list is how many runs of the job can overlap.
183
+ */
184
+ deployments: readonly string[];
185
+ /** The script parameter the run id is passed in; every stage reads the run back from it. */
186
+ runParameter: string;
187
+ /** The job's own script parameters, by the name the stages use. */
188
+ parameters?: Readonly<Record<string, string>>;
189
+ }
190
+ /** One field an application added to its run record, as netsuite-api.config.json declares it. */
191
+ export interface JobRunExtraField {
192
+ id: string;
193
+ type: NetsuiteValueType;
194
+ }
195
+ /**
196
+ * The run record as this application deployed it, written by the generator from the `jobRuns` block
197
+ * of netsuite-api.config.json. The field names are the package's; their ids are the application's,
198
+ * because the record carries the application's prefix.
199
+ */
200
+ export interface JobRunsConfig {
201
+ recordType: string;
202
+ fields: {
203
+ job: string;
204
+ status: string;
205
+ stage: string;
206
+ stagePercentComplete: string;
207
+ input: string;
208
+ result: string;
209
+ errors: string;
210
+ taskId: string;
211
+ deployment: string;
212
+ startedBy: string;
213
+ startedAt: string;
214
+ finishedAt: string;
215
+ };
216
+ /** Fields this application added, by the name the code uses for them. */
217
+ extraFields: Readonly<Record<string, JobRunExtraField>>;
218
+ }
@@ -6,4 +6,6 @@ export declare class ApiError extends Error {
6
6
  static badRequest(message: string, details?: unknown): ApiError;
7
7
  static notFound(message: string, details?: unknown): ApiError;
8
8
  static forbidden(message?: string): ApiError;
9
+ /** The request was fine but the account cannot take it now: every deployment of a job is already running. */
10
+ static conflict(message: string, details?: unknown): ApiError;
9
11
  }
@@ -15,4 +15,8 @@ export class ApiError extends Error {
15
15
  static forbidden(message = 'Not permitted') {
16
16
  return new ApiError(403, message);
17
17
  }
18
+ /** The request was fine but the account cannot take it now: every deployment of a job is already running. */
19
+ static conflict(message, details) {
20
+ return new ApiError(409, message, details);
21
+ }
18
22
  }
@@ -0,0 +1,95 @@
1
+ import type { EntryPoints } from 'N/types';
2
+ import type { JobParameterDeclaration, JobParameterValues, JobRunError, JobRunsConfig } from '../index.js';
3
+ /**
4
+ * A job is a Map/Reduce script written as stages instead of entry points:
5
+ *
6
+ * export const { getInputData, map, summarize } = defineJob({
7
+ * name: 'closeStaleOrders',
8
+ * scriptId: 'customscript_app_close_stale_orders_mr',
9
+ * deployments: ['customdeploy_app_close_stale_orders_mr', 'customdeploy_app_close_stale_orders_mr_2'],
10
+ * runParameter: 'custscript_app_close_stale_run',
11
+ * runs: jobRuns,
12
+ * }, {
13
+ * getInputData: (input: CloseStaleRequest): StaleOrder[] => listStaleOrders(input.olderThanDays),
14
+ * map: (order: StaleOrder, job) => { job.write(String(order.id), closeOrder(order.id)); },
15
+ * summarize: (summary): CloseStaleResult => ({ closed: summary.output.length }),
16
+ * });
17
+ *
18
+ * What the wrapper adds is the run: the values between stages are typed and parsed, getInputData's
19
+ * input comes from the run record rather than a parameter the stage has to decode, summarize's
20
+ * return value becomes the run's result, and the errors NetSuite collected are written onto the run
21
+ * with it. A stage that throws marks the run failed and rethrows, so the failure is in the execution
22
+ * log where NetSuite puts it and on the run where the page is looking.
23
+ *
24
+ * Export the stages the job has. Every job exports summarize even when it has no summarize stage of
25
+ * its own, because that is where the run is closed. A stage exported but not declared throws when
26
+ * NetSuite calls it, rather than quietly passing values through.
27
+ */
28
+ /** A job's script: what the generator reads, and what the run record's rows point back at. */
29
+ export interface JobDeclaration<TParameters extends Record<string, JobParameterDeclaration> = Record<string, never>> {
30
+ /** The job's name, as the run record records it and the scripts map keys it: the file name. */
31
+ name: string;
32
+ /** The script record's id, `customscript_<prefix>_<name>_mr`. */
33
+ scriptId: string;
34
+ /**
35
+ * Every deployment of the script, in the order startJob tries them. NetSuite runs one instance of
36
+ * a deployment at a time, so the length of this list is how many runs can overlap; a job started
37
+ * from a page answers 409 when they are all busy.
38
+ */
39
+ deployments: string[];
40
+ /** The script parameter carrying the run id, `custscript_<prefix>_<name>_run`. */
41
+ runParameter: string;
42
+ /** The job's own script parameters, by the name the stages use. */
43
+ parameters?: TParameters;
44
+ /** The application's run record, from the generated scripts map: `runs: jobRuns`. */
45
+ runs: JobRunsConfig;
46
+ }
47
+ /** What every stage is given besides its own values. */
48
+ export interface JobContext<TParameters extends Record<string, JobParameterDeclaration>> {
49
+ /** The run this stage belongs to, as the page polls it. */
50
+ readonly runId: string;
51
+ /** The script parameters the declaration names, typed. */
52
+ readonly parameters: JobParameterValues<TParameters>;
53
+ }
54
+ /** A stage that writes values for the stage after it. */
55
+ export interface JobWriteContext<TParameters extends Record<string, JobParameterDeclaration>, TValue> extends JobContext<TParameters> {
56
+ /** Hands one value to the next stage under a key; the value is carried as JSON and comes back typed. */
57
+ write(key: string, value: TValue): void;
58
+ }
59
+ /** What the run produced, as summarize sees it. */
60
+ export interface JobSummary<TOutput> {
61
+ /** Every key and value the reduce stage wrote, or the map stage's when the job has no reduce. */
62
+ output: {
63
+ key: string;
64
+ value: TOutput;
65
+ }[];
66
+ /** Everything that failed anywhere in the run; these are written onto the run whatever summarize returns. */
67
+ errors: JobRunError[];
68
+ seconds: number;
69
+ usage: number;
70
+ concurrency: number;
71
+ yields: number;
72
+ }
73
+ /**
74
+ * The stages of a job. getInputData's first parameter is the run's input and summarize's return type
75
+ * is the run's result: the generator reads both from those annotations, the way it reads an endpoint's
76
+ * request and response. The items getInputData returns are typed from there on.
77
+ *
78
+ * The values between map, reduce and summarize are carried as JSON, so each stage says what it
79
+ * expects: a reduce stage writes `values: number[]` and a summarize stage `summary: JobSummary<Total>`,
80
+ * and the wrapper hands them back as annotated. The stages are written as methods for that reason.
81
+ */
82
+ export interface JobStages<TInput, TItem, TValue = unknown, TOutput = unknown, TResult = null, TParameters extends Record<string, JobParameterDeclaration> = Record<string, never>> {
83
+ getInputData(input: TInput, job: JobContext<TParameters>): TItem[] | Record<string, TItem>;
84
+ map?(item: TItem, job: JobWriteContext<TParameters, TValue>): void;
85
+ reduce?(key: string, values: TValue[], job: JobWriteContext<TParameters, TOutput>): void;
86
+ summarize?(summary: JobSummary<TOutput>, job: JobContext<TParameters>): TResult;
87
+ }
88
+ /** The four NetSuite entry points, of which a job file exports the ones it has. */
89
+ export interface JobEntryPoints {
90
+ getInputData: (context: EntryPoints.MapReduce.getInputDataContext) => unknown;
91
+ map: (context: EntryPoints.MapReduce.mapContext) => void;
92
+ reduce: (context: EntryPoints.MapReduce.reduceContext) => void;
93
+ summarize: (context: EntryPoints.MapReduce.summarizeContext) => void;
94
+ }
95
+ export declare function defineJob<TInput, TItem, TValue = unknown, TOutput = unknown, TResult = null, TParameters extends Record<string, JobParameterDeclaration> = Record<string, never>>(declaration: JobDeclaration<TParameters>, stages: JobStages<TInput, TItem, TValue, TOutput, TResult, TParameters>): JobEntryPoints;
@@ -0,0 +1,150 @@
1
+ import * as log from 'N/log';
2
+ import * as runtime from 'N/runtime';
3
+ import { createJobRunStore } from './jobRuns.js';
4
+ function readParameterValue(id, type) {
5
+ const raw = runtime.getCurrentScript().getParameter({ name: id });
6
+ if (raw === null || raw === undefined || raw === '')
7
+ return type === 'checkbox' ? false : type === 'integer' || type === 'decimal' ? 0 : '';
8
+ if (type === 'integer' || type === 'decimal')
9
+ return Number(raw);
10
+ if (type === 'checkbox')
11
+ return raw === true || raw === 'T' || raw === 'true';
12
+ if (raw instanceof Date)
13
+ return raw.toISOString();
14
+ return String(raw);
15
+ }
16
+ function describeError(error) {
17
+ return error instanceof Error ? error.message : String(error);
18
+ }
19
+ /** The errors NetSuite collected, in the order the run met them. */
20
+ function readCollectedErrors(summary) {
21
+ var _a;
22
+ const errors = [];
23
+ if ((_a = summary.inputSummary) === null || _a === void 0 ? void 0 : _a.error)
24
+ errors.push({ stage: 'input', message: summary.inputSummary.error });
25
+ const collect = (stage, container) => {
26
+ container === null || container === void 0 ? void 0 : container.errors.iterator().each((key, error) => {
27
+ errors.push({ stage, key, message: error });
28
+ return true;
29
+ });
30
+ };
31
+ collect('map', summary.mapSummary);
32
+ collect('reduce', summary.reduceSummary);
33
+ return errors;
34
+ }
35
+ export function defineJob(declaration, stages) {
36
+ const store = createJobRunStore(declaration.runs);
37
+ const { name } = declaration;
38
+ const readParameters = () => {
39
+ var _a;
40
+ const values = {};
41
+ for (const [parameterName, parameter] of Object.entries((_a = declaration.parameters) !== null && _a !== void 0 ? _a : {}))
42
+ values[parameterName] = readParameterValue(parameter.id, parameter.type);
43
+ return values;
44
+ };
45
+ const readRunIdParameter = () => {
46
+ const raw = runtime.getCurrentScript().getParameter({ name: declaration.runParameter });
47
+ return typeof raw === 'string' && raw !== '' ? raw : undefined;
48
+ };
49
+ /**
50
+ * The run a stage belongs to. getInputData has it from the parameter or opens one; the stages
51
+ * after it read the parameter again, and a scheduled run (which has no parameter to read) is
52
+ * found by the deployment it is running on, one run at a time being all a deployment can do.
53
+ */
54
+ const buildContext = (runIdSource) => {
55
+ let resolvedRunId;
56
+ let resolvedParameters;
57
+ return {
58
+ get runId() {
59
+ resolvedRunId = resolvedRunId !== null && resolvedRunId !== void 0 ? resolvedRunId : runIdSource();
60
+ return resolvedRunId;
61
+ },
62
+ get parameters() {
63
+ resolvedParameters = resolvedParameters !== null && resolvedParameters !== void 0 ? resolvedParameters : readParameters();
64
+ return resolvedParameters;
65
+ },
66
+ };
67
+ };
68
+ const lateRunId = () => {
69
+ var _a, _b;
70
+ const deployment = String(runtime.getCurrentScript().deploymentId);
71
+ return (_b = (_a = readRunIdParameter()) !== null && _a !== void 0 ? _a : store.findRunningRunId(name, deployment)) !== null && _b !== void 0 ? _b : '';
72
+ };
73
+ /** A stage's own failure: logged, written onto the run when the run is the only place it would show, and rethrown for NetSuite. */
74
+ const runStage = (stage, context, markRun, work) => {
75
+ try {
76
+ return work();
77
+ }
78
+ catch (error) {
79
+ const message = describeError(error);
80
+ log.error('job stage failed', { job: name, stage, runId: context.runId, message });
81
+ if (markRun && context.runId !== '')
82
+ store.fail(context.runId, { stage, message });
83
+ throw error;
84
+ }
85
+ };
86
+ return {
87
+ getInputData(scriptContext) {
88
+ const deployment = String(runtime.getCurrentScript().deploymentId);
89
+ const runId = store.claimRun({ job: name, runId: readRunIdParameter(), deployment });
90
+ const context = buildContext(() => runId);
91
+ log.audit('job started', { job: name, runId, deployment, restarted: scriptContext.isRestarted });
92
+ return runStage('input', context, true, () => stages.getInputData(store.readInput(runId), context));
93
+ },
94
+ map(scriptContext) {
95
+ const context = buildContext(lateRunId);
96
+ runStage('map', context, false, () => {
97
+ if (!stages.map)
98
+ throw new Error(`${name} declares no map stage; remove map from this file's exports.`);
99
+ stages.map(JSON.parse(scriptContext.value), {
100
+ get runId() {
101
+ return context.runId;
102
+ },
103
+ get parameters() {
104
+ return context.parameters;
105
+ },
106
+ write: (key, value) => scriptContext.write({ key, value: JSON.stringify(value) }),
107
+ });
108
+ });
109
+ },
110
+ reduce(scriptContext) {
111
+ const context = buildContext(lateRunId);
112
+ runStage('reduce', context, false, () => {
113
+ if (!stages.reduce)
114
+ throw new Error(`${name} declares no reduce stage; remove reduce from this file's exports.`);
115
+ stages.reduce(scriptContext.key, scriptContext.values.map((value) => JSON.parse(value)), {
116
+ get runId() {
117
+ return context.runId;
118
+ },
119
+ get parameters() {
120
+ return context.parameters;
121
+ },
122
+ write: (key, value) => scriptContext.write({ key, value: JSON.stringify(value) }),
123
+ });
124
+ });
125
+ },
126
+ summarize(scriptContext) {
127
+ var _a;
128
+ const context = buildContext(lateRunId);
129
+ const errors = readCollectedErrors(scriptContext);
130
+ const output = [];
131
+ scriptContext.output.iterator().each((key, value) => {
132
+ output.push({ key, value: JSON.parse(value) });
133
+ return true;
134
+ });
135
+ const summary = {
136
+ output,
137
+ errors,
138
+ seconds: scriptContext.seconds,
139
+ usage: scriptContext.usage,
140
+ concurrency: scriptContext.concurrency,
141
+ yields: scriptContext.yields,
142
+ };
143
+ const result = runStage('summarize', context, true, () => (stages.summarize ? stages.summarize(summary, context) : null));
144
+ const status = ((_a = scriptContext.inputSummary) === null || _a === void 0 ? void 0 : _a.error) ? 'failed' : 'complete';
145
+ if (context.runId !== '')
146
+ store.finish(context.runId, { status, result, errors });
147
+ log.audit('job finished', { job: name, runId: context.runId, status, errors: errors.length, seconds: scriptContext.seconds, usage: scriptContext.usage });
148
+ },
149
+ };
150
+ }
@@ -83,7 +83,7 @@ export function invokeEndpoint(controllerName, endpoints, rawRequest, options =
83
83
  if (error instanceof ApiError) {
84
84
  status = error.status;
85
85
  log.debug('endpoint rejected', { controller: controllerName, endpoint: endpointName, status, message: error.message, details: error.details });
86
- return { envelope: { status, error: error.message, data: null } };
86
+ return { envelope: error.details === undefined ? { status, error: error.message, data: null } : { status, error: error.message, data: null, details: error.details } };
87
87
  }
88
88
  status = 500;
89
89
  log.error('endpoint failed', { controller: controllerName, endpoint: endpointName, ...describeError(error) });