@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
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * The server side of the API: declare a controller's endpoints, expose them as a Restlet or a
3
3
  * Suitelet, reject a call with an ApiError, authorize calls, answer with a document instead of JSON,
4
- * call another Suitelet controller from server code, and find a File Cabinet file by name. Runs
5
- * inside NetSuite only; the modules here import N/*.
4
+ * call another Suitelet controller from server code, write a Map/Reduce job and the record its runs
5
+ * live in, and find a File Cabinet file by name. Runs inside NetSuite only; the modules here import
6
+ * N/*.
6
7
  */
7
8
  export { ApiError } from './apiError.js';
8
9
  export { defineEndpoints, invokeEndpoint, parseEndpointRequest, readEndpointCall } from './endpoint.js';
9
10
  export type { AuthorizeEndpoint, ControllerOptions, EndpointCall, EndpointCallContext, EndpointOutcome, InvokeEndpointOptions } from './endpoint.js';
11
+ export { defineJob } from './defineJob.js';
12
+ export type { JobContext, JobDeclaration, JobEntryPoints, JobStages, JobSummary, JobWriteContext } from './defineJob.js';
13
+ export { createJobRunStore } from './jobRuns.js';
14
+ export type { ClaimRunDetails, FinishRunOutcome, JobRunStore, StartJobOptions } from './jobRuns.js';
10
15
  export { defineRestlet } from './defineRestlet.js';
11
16
  export type { RestletEntryPoint } from './defineRestlet.js';
12
17
  export { defineSuitelet } from './defineSuitelet.js';
@@ -18,4 +23,4 @@ export type { SuiteletClient } from './suiteletClient.js';
18
23
  export { findFileId, findFolderId, getFileUrlByName } from './fileCabinet.js';
19
24
  export type { FileCabinetLocation } from './fileCabinet.js';
20
25
  export { ENDPOINT_PARAMETER } from '../index.js';
21
- export type { ApiEnvelope, ApiErrorBody, Endpoint, Endpoints, EndpointRequest, EndpointResponse, RawResponse, ScriptDeclaration, ScriptKind, ScriptRef } from '../index.js';
26
+ export type { ApiEnvelope, ApiErrorBody, Endpoint, Endpoints, EndpointRequest, EndpointResponse, JobParameterDeclaration, JobParameterValue, JobParameterValues, JobRef, JobRun, JobRunError, JobRunListEntry, JobRunQuery, JobRunExtraField, JobRunsConfig, JobRunStage, JobRunStatus, NetsuiteValueType, RawResponse, ScriptDeclaration, ScriptKind, ScriptRef, } from '../index.js';
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * The server side of the API: declare a controller's endpoints, expose them as a Restlet or a
3
3
  * Suitelet, reject a call with an ApiError, authorize calls, answer with a document instead of JSON,
4
- * call another Suitelet controller from server code, and find a File Cabinet file by name. Runs
5
- * inside NetSuite only; the modules here import N/*.
4
+ * call another Suitelet controller from server code, write a Map/Reduce job and the record its runs
5
+ * live in, and find a File Cabinet file by name. Runs inside NetSuite only; the modules here import
6
+ * N/*.
6
7
  */
7
8
  export { ApiError } from './apiError.js';
8
9
  export { defineEndpoints, invokeEndpoint, parseEndpointRequest, readEndpointCall } from './endpoint.js';
10
+ export { defineJob } from './defineJob.js';
11
+ export { createJobRunStore } from './jobRuns.js';
9
12
  export { defineRestlet } from './defineRestlet.js';
10
13
  export { defineSuitelet } from './defineSuitelet.js';
11
14
  export { isRawResponse, rawResponse, writeRawResponse } from './rawResponse.js';
@@ -0,0 +1,65 @@
1
+ import type { JobRef, JobRun, JobRunError, JobRunListEntry, JobRunQuery, JobRunStatus, JobRunsConfig } from '../index.js';
2
+ /**
3
+ * The run record: everything a job run is, outside the job. A Map/Reduce script answers nothing and
4
+ * cannot be waited on, so starting one writes a record first and passes its id in as the script
5
+ * parameter; the stages read their input back from it and summarize writes the result to it. Reading
6
+ * a run asks NetSuite about the task as well, which is what tells a run that died apart from one
7
+ * still working: a record left at `running` under a task NetSuite has finished or given up on is a
8
+ * failure, not something to keep polling.
9
+ *
10
+ * The record is the application's own (`customrecord_<prefix>_job_run`), so the ids come in as the
11
+ * generated `jobRuns` config rather than being written here. A repository builds the store once and
12
+ * the jobs build their own from the same config; nothing is global.
13
+ */
14
+ /** What startJob may add to the run beyond the input: the application's own fields on the run record. */
15
+ export interface StartJobOptions {
16
+ /** Values for the fields declared in `jobRuns.extraFields`, by name. */
17
+ extra?: Record<string, unknown>;
18
+ }
19
+ /** What a stage's wrapper needs to find or open a run; not used by application code. */
20
+ export interface ClaimRunDetails {
21
+ job: string;
22
+ /** The run id the script parameter carried, or undefined for a scheduled run, which opens its own. */
23
+ runId?: string;
24
+ deployment: string;
25
+ }
26
+ /** How a run ended, as summarize leaves it. */
27
+ export interface FinishRunOutcome {
28
+ status: Extract<JobRunStatus, 'complete' | 'failed'>;
29
+ result: unknown;
30
+ errors: JobRunError[];
31
+ }
32
+ export interface JobRunStore {
33
+ /**
34
+ * Starts a job: writes the run, then submits the task to the first deployment that takes it. The
35
+ * run id is what a page polls with. Throws a 409 when every deployment is already running, and
36
+ * leaves no run behind when it does, because nothing started.
37
+ */
38
+ start(job: JobRef, input?: unknown, options?: StartJobOptions): string;
39
+ /** The run as anyone asking sees it: the record refined by what NetSuite says about the task. Null when the record is gone (cleaned up, or never existed). */
40
+ read<TResult = unknown, TExtra extends Record<string, unknown> = Record<string, never>>(runId: string): JobRun<TResult, TExtra> | null;
41
+ /**
42
+ * The runs matching the query, newest first: one query, no record loads, so a page can find the run it
43
+ * lost track of. The rows carry what the record says and not what the task says, so read a run by id
44
+ * before believing it is still working.
45
+ */
46
+ findRuns(runQuery?: JobRunQuery): JobRunListEntry[];
47
+ /** Run ids older than the given number of days, for the cleanup job. */
48
+ findExpired(olderThanDays: number): string[];
49
+ remove(runId: string): void;
50
+ /** Opens the run a stage belongs to, creating one for a scheduled run. Called by the job wrapper. */
51
+ claimRun(details: ClaimRunDetails): string;
52
+ /** The input the run was started with; `{}` for a scheduled run. Called by the job wrapper. */
53
+ readInput<TInput>(runId: string): TInput;
54
+ /** The run id of whatever is running on this deployment, for the stages after getInputData. Called by the job wrapper. */
55
+ findRunningRunId(job: string, deployment: string): string | undefined;
56
+ /** Writes the result and the errors, and closes the run. Called by the job wrapper. */
57
+ finish(runId: string, outcome: FinishRunOutcome): void;
58
+ /** Marks the run failed with one error, for a stage that threw. Called by the job wrapper. */
59
+ fail(runId: string, error: JobRunError): void;
60
+ }
61
+ /**
62
+ * The store for one application's run record. Build it once where it is used: a repository for the
63
+ * application's own calls, and the job wrapper for the stages.
64
+ */
65
+ export declare function createJobRunStore(config: JobRunsConfig): JobRunStore;
@@ -0,0 +1,292 @@
1
+ import * as log from 'N/log';
2
+ import * as query from 'N/query';
3
+ import * as record from 'N/record';
4
+ import * as runtime from 'N/runtime';
5
+ import * as task from 'N/task';
6
+ import { ApiError } from './apiError.js';
7
+ const STAGE_BY_TASK_STAGE = {
8
+ GET_INPUT: 'input',
9
+ MAP: 'map',
10
+ SHUFFLE: 'shuffle',
11
+ REDUCE: 'reduce',
12
+ SUMMARIZE: 'summarize',
13
+ };
14
+ /** NetSuite hands a field back as whatever it stores; the declared type says what the code asked for. */
15
+ function readTypedValue(raw, type) {
16
+ if (raw === null || raw === undefined || raw === '')
17
+ return null;
18
+ if (type === 'integer' || type === 'decimal') {
19
+ const parsed = Number(raw);
20
+ return Number.isNaN(parsed) ? null : parsed;
21
+ }
22
+ if (type === 'checkbox')
23
+ return raw === true || raw === 'T' || raw === 'true';
24
+ if (raw instanceof Date)
25
+ return raw.toISOString();
26
+ return String(raw);
27
+ }
28
+ function writeTypedValue(value, type) {
29
+ if (value === null || value === undefined)
30
+ return '';
31
+ if (type === 'integer' || type === 'decimal')
32
+ return Number(value);
33
+ if (type === 'checkbox')
34
+ return Boolean(value);
35
+ if (type === 'date')
36
+ return value instanceof Date ? value : String(value);
37
+ return String(value);
38
+ }
39
+ function parseJsonField(raw, fallback) {
40
+ if (typeof raw !== 'string' || raw.trim() === '')
41
+ return fallback;
42
+ try {
43
+ return JSON.parse(raw);
44
+ }
45
+ catch {
46
+ return fallback;
47
+ }
48
+ }
49
+ function readDateField(raw) {
50
+ if (raw instanceof Date)
51
+ return raw.toISOString();
52
+ if (typeof raw === 'string' && raw !== '')
53
+ return raw;
54
+ return null;
55
+ }
56
+ /**
57
+ * The store for one application's run record. Build it once where it is used: a repository for the
58
+ * application's own calls, and the job wrapper for the stages.
59
+ */
60
+ export function createJobRunStore(config) {
61
+ const { recordType, fields, extraFields } = config;
62
+ function loadRun(runId) {
63
+ try {
64
+ return record.load({ type: recordType, id: runId });
65
+ }
66
+ catch (error) {
67
+ log.debug('job run missing', { record: recordType, runId, message: error instanceof Error ? error.message : String(error) });
68
+ return undefined;
69
+ }
70
+ }
71
+ function writeFields(runId, values) {
72
+ const runRecord = loadRun(runId);
73
+ if (!runRecord)
74
+ return;
75
+ for (const [fieldId, value] of Object.entries(values))
76
+ runRecord.setValue({ fieldId, value });
77
+ runRecord.save({ ignoreMandatoryFields: true });
78
+ }
79
+ function createRun(job, input, extra, startedBy) {
80
+ const runRecord = record.create({ type: recordType });
81
+ runRecord.setValue({ fieldId: 'name', value: job });
82
+ runRecord.setValue({ fieldId: fields.job, value: job });
83
+ runRecord.setValue({ fieldId: fields.status, value: 'pending' });
84
+ runRecord.setValue({ fieldId: fields.input, value: JSON.stringify(input !== null && input !== void 0 ? input : {}) });
85
+ if (startedBy !== null)
86
+ runRecord.setValue({ fieldId: fields.startedBy, value: startedBy });
87
+ for (const [name, value] of Object.entries(extra !== null && extra !== void 0 ? extra : {})) {
88
+ const field = extraFields[name];
89
+ if (!field)
90
+ throw new Error(`The run record has no field '${name}'; declare it in the jobRuns block of netsuite-api.config.json.`);
91
+ runRecord.setValue({ fieldId: field.id, value: writeTypedValue(value, field.type) });
92
+ }
93
+ return String(runRecord.save({ ignoreMandatoryFields: true }));
94
+ }
95
+ /** The task as NetSuite sees it, or undefined when it can no longer say (an id it has purged). */
96
+ function checkTask(taskId) {
97
+ var _a, _b;
98
+ try {
99
+ const status = task.checkStatus({ taskId });
100
+ /** One of the task's counts, called on the task itself, or null when it has none to give. */
101
+ const readCount = (getterName) => {
102
+ const getter = status[getterName];
103
+ if (typeof getter !== 'function')
104
+ return null;
105
+ const count = Number(getter.call(status));
106
+ return Number.isFinite(count) ? count : null;
107
+ };
108
+ const stage = (_a = status.stage) !== null && _a !== void 0 ? _a : undefined;
109
+ // Every count belongs to the stage being worked, so only map and reduce have one to give.
110
+ const total = stage === 'MAP' ? readCount('getTotalMapCount') : stage === 'REDUCE' ? readCount('getTotalReduceCount') : null;
111
+ const pending = stage === 'MAP' ? readCount('getPendingMapCount') : stage === 'REDUCE' ? readCount('getPendingReduceCount') : null;
112
+ return {
113
+ status: (_b = status.status) !== null && _b !== void 0 ? _b : undefined,
114
+ stage,
115
+ stagePercentComplete: typeof status.getPercentageCompleted === 'function' ? Number(status.getPercentageCompleted()) || 0 : 0,
116
+ itemsTotal: total,
117
+ itemsProcessed: total === null || pending === null ? null : Math.max(total - pending, 0),
118
+ };
119
+ }
120
+ catch (error) {
121
+ log.debug('job task unknown', { taskId, message: error instanceof Error ? error.message : String(error) });
122
+ return undefined;
123
+ }
124
+ }
125
+ return {
126
+ start(job, input, options = {}) {
127
+ const startedBy = runtime.getCurrentUser().id;
128
+ const runId = createRun(job.name, input, options.extra, typeof startedBy === 'number' ? startedBy : null);
129
+ const refusals = [];
130
+ for (const deployment of job.deployments) {
131
+ try {
132
+ const submitted = task
133
+ .create({ taskType: task.TaskType.MAP_REDUCE, scriptId: job.scriptId, deploymentId: deployment, params: { [job.runParameter]: runId } })
134
+ .submit();
135
+ writeFields(runId, { [fields.taskId]: submitted, [fields.deployment]: deployment });
136
+ log.audit('job submitted', { job: job.name, runId, taskId: submitted, deployment });
137
+ return runId;
138
+ }
139
+ catch (error) {
140
+ refusals.push(`${deployment}: ${error instanceof Error ? error.message : String(error)}`);
141
+ }
142
+ }
143
+ // Nothing started, so nothing is left to poll: the run is removed and the caller is told to try later.
144
+ record.delete({ type: recordType, id: runId });
145
+ log.audit('job not started', { job: job.name, deployments: job.deployments.length, refusals });
146
+ throw ApiError.conflict(`${job.name} is already running on every deployment it has; try again shortly.`, { job: job.name, deployments: job.deployments.length });
147
+ },
148
+ read: function readRun(runId) {
149
+ var _a;
150
+ const runRecord = loadRun(runId);
151
+ if (!runRecord)
152
+ return null;
153
+ const readValue = (fieldId) => runRecord.getValue({ fieldId });
154
+ let status = (readValue(fields.status) || 'pending');
155
+ let stage = (readValue(fields.stage) || null);
156
+ let stagePercentComplete = Number(readValue(fields.stagePercentComplete)) || 0;
157
+ // Counts come from the task alone, so they are there while it is working and null once it is not.
158
+ let itemsProcessed = null;
159
+ let itemsTotal = null;
160
+ const errors = parseJsonField(readValue(fields.errors), []);
161
+ const taskId = (readValue(fields.taskId) || null);
162
+ // What NetSuite says about the task decides between "still working" and "died on the way".
163
+ if (taskId !== null && (status === 'pending' || status === 'running')) {
164
+ const taskStatus = checkTask(taskId);
165
+ if ((taskStatus === null || taskStatus === void 0 ? void 0 : taskStatus.status) === 'FAILED') {
166
+ status = 'failed';
167
+ errors.push({ stage: stage !== null && stage !== void 0 ? stage : 'input', message: 'NetSuite stopped the task before it finished.' });
168
+ }
169
+ else if ((taskStatus === null || taskStatus === void 0 ? void 0 : taskStatus.status) === 'COMPLETE') {
170
+ // summarize writes the result before the task completes, so a complete task over a running record means it never got there.
171
+ status = 'failed';
172
+ errors.push({ stage: stage !== null && stage !== void 0 ? stage : 'summarize', message: 'The task finished without writing a result; see the script execution log.' });
173
+ }
174
+ else if ((taskStatus === null || taskStatus === void 0 ? void 0 : taskStatus.status) === 'PROCESSING') {
175
+ status = 'running';
176
+ stage = (taskStatus.stage && STAGE_BY_TASK_STAGE[taskStatus.stage]) || stage;
177
+ stagePercentComplete = taskStatus.stagePercentComplete || stagePercentComplete;
178
+ itemsProcessed = taskStatus.itemsProcessed;
179
+ itemsTotal = taskStatus.itemsTotal;
180
+ }
181
+ }
182
+ const extra = {};
183
+ for (const [name, field] of Object.entries(extraFields))
184
+ extra[name] = readTypedValue(readValue(field.id), field.type);
185
+ const startedBy = Number(readValue(fields.startedBy));
186
+ return {
187
+ id: runId,
188
+ job: String((_a = readValue(fields.job)) !== null && _a !== void 0 ? _a : ''),
189
+ status,
190
+ stage,
191
+ stagePercentComplete,
192
+ itemsProcessed,
193
+ itemsTotal,
194
+ startedBy: Number.isNaN(startedBy) || startedBy === 0 ? null : startedBy,
195
+ startedAt: readDateField(readValue(fields.startedAt)),
196
+ finishedAt: readDateField(readValue(fields.finishedAt)),
197
+ taskId,
198
+ result: parseJsonField(readValue(fields.result), null),
199
+ errors,
200
+ extra: extra,
201
+ };
202
+ },
203
+ findRuns(runQuery = {}) {
204
+ var _a;
205
+ const conditions = [];
206
+ const params = [];
207
+ if (runQuery.job !== undefined) {
208
+ conditions.push(`${fields.job} = ?`);
209
+ params.push(runQuery.job);
210
+ }
211
+ if (runQuery.startedBy !== undefined) {
212
+ conditions.push(`${fields.startedBy} = ?`);
213
+ params.push(runQuery.startedBy);
214
+ }
215
+ if (runQuery.unfinishedOnly === true)
216
+ conditions.push(`(${fields.status} IS NULL OR ${fields.status} IN ('pending', 'running'))`);
217
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
218
+ const limit = Math.min(Math.max((_a = runQuery.limit) !== null && _a !== void 0 ? _a : 10, 1), 100);
219
+ const rows = query
220
+ .runSuiteQL({
221
+ query: `SELECT id, ${fields.job} AS job, ${fields.status} AS status, ${fields.stage} AS stage, ${fields.startedBy} AS startedby FROM ${recordType}${where} ORDER BY id DESC FETCH FIRST ${limit} ROWS ONLY`,
222
+ params,
223
+ })
224
+ .asMappedResults();
225
+ return rows.map((row) => {
226
+ var _a;
227
+ return ({
228
+ id: String(row.id),
229
+ job: String((_a = row.job) !== null && _a !== void 0 ? _a : ''),
230
+ status: (row.status || 'pending'),
231
+ stage: (row.stage || null),
232
+ startedBy: row.startedby === null || row.startedby === undefined || row.startedby === '' ? null : Number(row.startedby),
233
+ });
234
+ });
235
+ },
236
+ findExpired(olderThanDays) {
237
+ const results = query
238
+ .runSuiteQL({ query: `SELECT id FROM ${recordType} WHERE created < SYSDATE - ?`, params: [olderThanDays] })
239
+ .asMappedResults();
240
+ return results.map((row) => String(row.id));
241
+ },
242
+ remove(runId) {
243
+ record.delete({ type: recordType, id: runId });
244
+ },
245
+ claimRun({ job, runId, deployment }) {
246
+ const claimed = runId !== null && runId !== void 0 ? runId : createRun(job, {}, undefined, null);
247
+ writeFields(claimed, {
248
+ [fields.status]: 'running',
249
+ [fields.stage]: 'input',
250
+ [fields.deployment]: deployment,
251
+ [fields.startedAt]: new Date(),
252
+ });
253
+ return claimed;
254
+ },
255
+ readInput(runId) {
256
+ const runRecord = loadRun(runId);
257
+ if (!runRecord)
258
+ return {};
259
+ return parseJsonField(runRecord.getValue({ fieldId: fields.input }), {});
260
+ },
261
+ findRunningRunId(job, deployment) {
262
+ const results = query
263
+ .runSuiteQL({
264
+ query: `SELECT id FROM ${recordType} WHERE ${fields.job} = ? AND ${fields.deployment} = ? AND ${fields.status} = 'running' ORDER BY id DESC`,
265
+ params: [job, deployment],
266
+ })
267
+ .asMappedResults();
268
+ return results.length > 0 ? String(results[0].id) : undefined;
269
+ },
270
+ finish(runId, { status, result, errors }) {
271
+ writeFields(runId, {
272
+ [fields.status]: status,
273
+ [fields.stage]: 'summarize',
274
+ [fields.stagePercentComplete]: 100,
275
+ [fields.result]: JSON.stringify(result !== null && result !== void 0 ? result : null),
276
+ [fields.errors]: JSON.stringify(errors),
277
+ [fields.finishedAt]: new Date(),
278
+ });
279
+ },
280
+ fail(runId, error) {
281
+ const runRecord = loadRun(runId);
282
+ if (!runRecord)
283
+ return;
284
+ const errors = parseJsonField(runRecord.getValue({ fieldId: fields.errors }), []);
285
+ errors.push(error);
286
+ runRecord.setValue({ fieldId: fields.status, value: 'failed' });
287
+ runRecord.setValue({ fieldId: fields.errors, value: JSON.stringify(errors) });
288
+ runRecord.setValue({ fieldId: fields.finishedAt, value: new Date() });
289
+ runRecord.save({ ignoreMandatoryFields: true });
290
+ },
291
+ };
292
+ }
@@ -35,7 +35,8 @@ export function callSuiteletEndpoint(scriptRef, endpointName, request = {}) {
35
35
  }
36
36
  const envelope = parseEnvelope(scriptRef, endpointName, response.body);
37
37
  if (envelope.error !== null || envelope.status >= 400) {
38
- throw new ApiError(envelope.status, (_a = envelope.error) !== null && _a !== void 0 ? _a : `Request failed (${envelope.status})`, { script: scriptRef.scriptId, endpoint: endpointName });
38
+ // The called script's details travel on, so the caller (and through it the browser) can act on them.
39
+ throw new ApiError(envelope.status, (_a = envelope.error) !== null && _a !== void 0 ? _a : `Request failed (${envelope.status})`, envelope.details !== undefined ? envelope.details : { script: scriptRef.scriptId, endpoint: endpointName });
39
40
  }
40
41
  return envelope.data;
41
42
  }
@@ -1,7 +1,21 @@
1
1
  import { type Mock } from 'vitest';
2
2
  export declare const create: Mock;
3
+ /** A job run reads this to tell a task still working from one NetSuite gave up on; a test decides which by returning a status here. */
3
4
  export declare const checkStatus: Mock;
4
5
  export declare const TaskType: {
5
6
  MAP_REDUCE: string;
6
7
  SCHEDULED_SCRIPT: string;
7
8
  };
9
+ export declare const TaskStatus: {
10
+ PENDING: string;
11
+ PROCESSING: string;
12
+ COMPLETE: string;
13
+ FAILED: string;
14
+ };
15
+ export declare const MapReduceStage: {
16
+ GET_INPUT: string;
17
+ MAP: string;
18
+ SHUFFLE: string;
19
+ REDUCE: string;
20
+ SUMMARIZE: string;
21
+ };
@@ -1,4 +1,7 @@
1
1
  import { vi } from 'vitest';
2
2
  export const create = vi.fn(() => ({ submit: vi.fn(() => 'TASK_ID') }));
3
- export const checkStatus = vi.fn();
3
+ /** A job run reads this to tell a task still working from one NetSuite gave up on; a test decides which by returning a status here. */
4
+ export const checkStatus = vi.fn(() => ({ status: 'PROCESSING', stage: 'MAP', getPercentageCompleted: () => 0 }));
4
5
  export const TaskType = { MAP_REDUCE: 'MAP_REDUCE', SCHEDULED_SCRIPT: 'SCHEDULED_SCRIPT' };
6
+ export const TaskStatus = { PENDING: 'PENDING', PROCESSING: 'PROCESSING', COMPLETE: 'COMPLETE', FAILED: 'FAILED' };
7
+ export const MapReduceStage = { GET_INPUT: 'GET_INPUT', MAP: 'MAP', SHUFFLE: 'SHUFFLE', REDUCE: 'REDUCE', SUMMARIZE: 'SUMMARIZE' };
@@ -9,6 +9,9 @@
9
9
  *
10
10
  * This module runs in the vitest config, so it never imports vitest itself.
11
11
  */
12
+ /** The Map/Reduce contexts a job's stages are called with, as plain objects: what a job test builds its calls from. */
13
+ export { getInputDataContextFor, mapContextFor, reduceContextFor, summarizeContextFor } from './jobs.js';
14
+ export type { CapturedJobValue, MapContextFake, ReduceContextFake } from './jobs.js';
12
15
  /** Absolute directory of the stub modules, one file per N/* module (N/ui/serverWidget included). */
13
16
  export declare const netsuiteModuleStubsDirectory: string;
14
17
  export interface ModuleAlias {
@@ -12,6 +12,8 @@ import { fileURLToPath } from 'node:url';
12
12
  *
13
13
  * This module runs in the vitest config, so it never imports vitest itself.
14
14
  */
15
+ /** The Map/Reduce contexts a job's stages are called with, as plain objects: what a job test builds its calls from. */
16
+ export { getInputDataContextFor, mapContextFor, reduceContextFor, summarizeContextFor } from './jobs.js';
15
17
  /** Absolute directory of the stub modules, one file per N/* module (N/ui/serverWidget included). */
16
18
  export const netsuiteModuleStubsDirectory = nodePath.join(nodePath.dirname(fileURLToPath(import.meta.url)), 'N');
17
19
  /**
@@ -0,0 +1,50 @@
1
+ import type { EntryPoints } from 'N/types';
2
+ /**
3
+ * The contexts NetSuite hands a Map/Reduce script, as plain objects a test can build. A job's stages
4
+ * are called through its entry points, so a test calls the entry point with one of these and reads
5
+ * what it wrote: `map(mapContextFor({ value: order }))`, then `written` for what reached the next
6
+ * stage. Nothing here imports vitest, so it is safe in a vitest config's module graph as well.
7
+ */
8
+ /** One key and value as a stage wrote it, already parsed back from the JSON the stage carried it as. */
9
+ export interface CapturedJobValue<TValue = unknown> {
10
+ key: string;
11
+ value: TValue;
12
+ }
13
+ export interface MapContextFake extends EntryPoints.MapReduce.mapContext {
14
+ /** Everything the stage wrote, in order. */
15
+ written: CapturedJobValue[];
16
+ }
17
+ export interface ReduceContextFake extends EntryPoints.MapReduce.reduceContext {
18
+ written: CapturedJobValue[];
19
+ }
20
+ /** The getInputData context. The input itself comes from the run record, so a test stubs the store, not this. */
21
+ export declare function getInputDataContextFor(options?: {
22
+ isRestarted?: boolean;
23
+ }): EntryPoints.MapReduce.getInputDataContext;
24
+ /** The map context for one item; `value` is given as the item itself, the way getInputData returned it. */
25
+ export declare function mapContextFor(options: {
26
+ value: unknown;
27
+ key?: string;
28
+ isRestarted?: boolean;
29
+ }): MapContextFake;
30
+ /** The reduce context for one key; `values` are given as the values themselves, the way the map stage wrote them. */
31
+ export declare function reduceContextFor(options: {
32
+ key: string;
33
+ values: unknown[];
34
+ isRestarted?: boolean;
35
+ }): ReduceContextFake;
36
+ /** The summarize context: what the reduce stage left, and whatever failed on the way. */
37
+ export declare function summarizeContextFor(options?: {
38
+ output?: CapturedJobValue[];
39
+ inputError?: string;
40
+ mapErrors?: {
41
+ key: string;
42
+ error: string;
43
+ }[];
44
+ reduceErrors?: {
45
+ key: string;
46
+ error: string;
47
+ }[];
48
+ seconds?: number;
49
+ usage?: number;
50
+ }): EntryPoints.MapReduce.summarizeContext;
@@ -0,0 +1,90 @@
1
+ function emptyErrorIterator() {
2
+ return { iterator: () => ({ each: () => undefined }) };
3
+ }
4
+ function errorIterator(errors) {
5
+ return {
6
+ iterator: () => ({
7
+ each: (callback) => {
8
+ for (const entry of errors)
9
+ if (!callback(entry.key, entry.error, 1))
10
+ break;
11
+ },
12
+ }),
13
+ };
14
+ }
15
+ function captureWrites(written) {
16
+ return (keyOrOptions, value) => {
17
+ const pair = typeof keyOrOptions === 'string' ? { key: keyOrOptions, value } : keyOrOptions;
18
+ written.push({ key: String(pair.key), value: typeof pair.value === 'string' ? JSON.parse(pair.value) : pair.value });
19
+ };
20
+ }
21
+ /** The getInputData context. The input itself comes from the run record, so a test stubs the store, not this. */
22
+ export function getInputDataContextFor(options = {}) {
23
+ var _a;
24
+ return { isRestarted: (_a = options.isRestarted) !== null && _a !== void 0 ? _a : false, ObjectRef: { id: '', type: '' } };
25
+ }
26
+ /** The map context for one item; `value` is given as the item itself, the way getInputData returned it. */
27
+ export function mapContextFor(options) {
28
+ var _a, _b;
29
+ const written = [];
30
+ return {
31
+ isRestarted: (_a = options.isRestarted) !== null && _a !== void 0 ? _a : false,
32
+ executionNo: 1,
33
+ errors: emptyErrorIterator(),
34
+ key: (_b = options.key) !== null && _b !== void 0 ? _b : '0',
35
+ value: JSON.stringify(options.value),
36
+ write: captureWrites(written),
37
+ written,
38
+ };
39
+ }
40
+ /** The reduce context for one key; `values` are given as the values themselves, the way the map stage wrote them. */
41
+ export function reduceContextFor(options) {
42
+ var _a;
43
+ const written = [];
44
+ return {
45
+ isRestarted: (_a = options.isRestarted) !== null && _a !== void 0 ? _a : false,
46
+ executionNo: 1,
47
+ errors: emptyErrorIterator(),
48
+ key: options.key,
49
+ values: options.values.map((value) => JSON.stringify(value)),
50
+ write: captureWrites(written),
51
+ written,
52
+ };
53
+ }
54
+ /** The summarize context: what the reduce stage left, and whatever failed on the way. */
55
+ export function summarizeContextFor(options = {}) {
56
+ var _a, _b, _c, _d, _e, _f, _g, _h;
57
+ const output = (_a = options.output) !== null && _a !== void 0 ? _a : [];
58
+ const stageSummary = (errors) => {
59
+ var _a, _b;
60
+ return ({
61
+ dateCreated: new Date(),
62
+ seconds: (_a = options.seconds) !== null && _a !== void 0 ? _a : 0,
63
+ usage: (_b = options.usage) !== null && _b !== void 0 ? _b : 0,
64
+ concurrency: 1,
65
+ yields: 0,
66
+ keys: { iterator: () => ({ each: () => undefined }) },
67
+ errors: errors.length > 0 ? errorIterator(errors) : emptyErrorIterator(),
68
+ });
69
+ };
70
+ return {
71
+ isRestarted: false,
72
+ dateCreated: new Date(),
73
+ seconds: (_b = options.seconds) !== null && _b !== void 0 ? _b : 0,
74
+ usage: (_c = options.usage) !== null && _c !== void 0 ? _c : 0,
75
+ concurrency: 1,
76
+ yields: 0,
77
+ inputSummary: { dateCreated: new Date(), error: (_d = options.inputError) !== null && _d !== void 0 ? _d : '', seconds: (_e = options.seconds) !== null && _e !== void 0 ? _e : 0, usage: (_f = options.usage) !== null && _f !== void 0 ? _f : 0 },
78
+ mapSummary: stageSummary((_g = options.mapErrors) !== null && _g !== void 0 ? _g : []),
79
+ reduceSummary: stageSummary((_h = options.reduceErrors) !== null && _h !== void 0 ? _h : []),
80
+ output: {
81
+ iterator: () => ({
82
+ each: (callback) => {
83
+ for (const entry of output)
84
+ if (!callback(entry.key, JSON.stringify(entry.value)))
85
+ break;
86
+ },
87
+ }),
88
+ },
89
+ };
90
+ }
@@ -7,7 +7,7 @@ export const CLI_USAGE = [
7
7
  'Usage: netsuite-api <command> [options]',
8
8
  '',
9
9
  'Commands:',
10
- ' generate Read the controllers and the app file; write a client module per controller, the client index, the app module and the scripts map, and delete a generated file no controller owns.',
10
+ ' generate Read the controllers and the jobs; write a module per controller and per job, the client index, the jobs index and the scripts map, and delete a generated file nothing owns.',
11
11
  ' check Exit non-zero when a generated file is missing, out of date or left over.',
12
12
  ' help Show this message.',
13
13
  '',
@@ -26,7 +26,10 @@ function relativeTo(cwd, filePath) {
26
26
  return toPosixPath(nodePath.relative(cwd, filePath)) || filePath;
27
27
  }
28
28
  function describeControllers(plan) {
29
- return plan.controllers.map((controller) => ` - ${controller.name} (${controller.kind}): ${controller.endpointCount} endpoint(s)${controller.browser ? '' : ', types only'}`);
29
+ return [
30
+ ...plan.controllers.map((controller) => ` - ${controller.name} (${controller.kind}): ${controller.endpointCount} endpoint(s)${controller.browser ? '' : ', types only'}`),
31
+ ...plan.jobs.map((job) => ` - ${job.name} (job): ${job.stages.join(', ')}, ${job.deploymentCount} deployment(s)`),
32
+ ];
30
33
  }
31
34
  /** Runs the CLI and resolves to the process exit code. Never throws for user errors. */
32
35
  export function runCli(argv, environment) {
@@ -66,7 +69,7 @@ export function runCli(argv, environment) {
66
69
  if (result.problems.length > 0)
67
70
  return failure(result);
68
71
  environment.stdout([
69
- `netsuite-api: ${result.controllers.length} controller(s), ${result.writtenFiles.length} file(s) written, ${result.unchangedFiles.length} unchanged${result.deletedFiles.length > 0 ? `, ${result.deletedFiles.length} deleted` : ''}.`,
72
+ `netsuite-api: ${result.controllers.length} controller(s)${result.jobs.length > 0 ? `, ${result.jobs.length} job(s)` : ''}, ${result.writtenFiles.length} file(s) written, ${result.unchangedFiles.length} unchanged${result.deletedFiles.length > 0 ? `, ${result.deletedFiles.length} deleted` : ''}.`,
70
73
  ...describeControllers(result),
71
74
  ...result.writtenFiles.map((filePath) => ` - wrote ${relativeTo(environment.cwd, filePath)}`),
72
75
  ...result.deletedFiles.map((filePath) => ` - deleted ${relativeTo(environment.cwd, filePath)}`),