@amerilux/netsuite-api 0.6.0 → 0.6.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.
package/README.md CHANGED
@@ -232,6 +232,8 @@ export const { getInputData, map, summarize } = defineJob({
232
232
 
233
233
  Each stage is then a file named after the stage NetSuite calls, holding the work and the shapes on its own boundary — open map.ts to see what the map stage does:
234
234
 
235
+ Only the result has to be a shape the client can carry, because it is the only one a browser is handed. The items a run is planned into are the stage's own working types and stay on the server, so they may be built on whatever the server has — a carrier's API request, a customer's configuration — and the generator does not ask them to be carryable.
236
+
235
237
  ```ts
236
238
  // api/src/jobs/closeStaleOrders/getInputData.ts
237
239
  import { listStaleOrders, type StaleOrder } from '../../services/staleOrderService';
@@ -242,7 +244,7 @@ export interface CloseStaleRequest { olderThanDays: number }
242
244
  export const getInputDataFunction = (input: CloseStaleRequest): StaleOrder[] => listStaleOrders(input.olderThanDays);
243
245
  ```
244
246
 
245
- A stage file exports the stage's name plus `Function`, because the definition file exports the plain names NetSuite looks for and the two would collide. A stage may also be written inline in the definition file, which suits a job whose stages are a line each — the cleanup job `npm run add:jobs` writes is one.
247
+ A stage file exports the stage's name plus `Function`, because the definition file exports the plain names NetSuite looks for and the two would collide. A stage file may name a shape another file of the job's folder declares — the value a map stage writes is declared in map.ts and named again in summarize.ts — and the generator reads it there. A stage may also be written inline in the definition file, which suits a job whose stages are a line each — the cleanup job `npm run add:jobs` writes is one.
246
248
 
247
249
  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 wherever the stage is written, the way it reads an endpoint's request and response, and copies the shapes the result names into the browser's module whether the stage file declares them or takes them from a service. 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.
248
250
 
@@ -10,6 +10,8 @@ import ts from 'typescript';
10
10
  export interface ControllerProblem {
11
11
  filePath: string;
12
12
  message: string;
13
+ /** The type the problem is about, when it is about one: what lets a job drop a problem its result never reaches. */
14
+ about?: string;
13
15
  }
14
16
  export interface EndpointSignature {
15
17
  name: string;
@@ -75,6 +75,16 @@ export declare function emitClientIndexModule(controllers: EmittedController[],
75
75
  export declare function sortJobs(jobs: EmittedJob[]): EmittedJob[];
76
76
  /** A job's module: the shapes a run is started with and ends in, and the types they are built from. */
77
77
  export declare function emitJobModule({ contract, sourceLabel, inlinedTypes }: EmittedJob): string;
78
+ /**
79
+ * The declared shapes a type names, and the shapes those name in turn: what a module has to carry for the
80
+ * type to stand on its own. A name with no declaration here belongs to the package or to the language, and
81
+ * the type import that brought it is what carries it.
82
+ *
83
+ * Every name the walk reaches is in the answer, including one nothing declares: a caller asking whether a
84
+ * type matters to the wire needs the names that could not be resolved as much as the ones that could. What
85
+ * is emitted is filtered against declarations, so the unresolved names add nothing to a module.
86
+ */
87
+ export declare function findTypesReachedBy(type: string, ownDeclarations: TypeDeclaration[], inlinedTypes: InlinedTypeSection[]): Set<string>;
78
88
  /** The jobs index: every job module under the job's name, reached as `jobs.<name>` from the client index. */
79
89
  export declare function emitJobsIndexModule(jobs: EmittedJob[], options: {
80
90
  jobsLabel: string;
@@ -146,8 +146,12 @@ export function emitJobModule({ contract, sourceLabel, inlinedTypes }) {
146
146
  * The declared shapes a type names, and the shapes those name in turn: what a module has to carry for the
147
147
  * type to stand on its own. A name with no declaration here belongs to the package or to the language, and
148
148
  * the type import that brought it is what carries it.
149
+ *
150
+ * Every name the walk reaches is in the answer, including one nothing declares: a caller asking whether a
151
+ * type matters to the wire needs the names that could not be resolved as much as the ones that could. What
152
+ * is emitted is filtered against declarations, so the unresolved names add nothing to a module.
149
153
  */
150
- function findTypesReachedBy(type, ownDeclarations, inlinedTypes) {
154
+ export function findTypesReachedBy(type, ownDeclarations, inlinedTypes) {
151
155
  const declarationsByName = new Map();
152
156
  for (const section of inlinedTypes)
153
157
  for (const declaration of section.declarations)
@@ -160,10 +164,10 @@ function findTypesReachedBy(type, ownDeclarations, inlinedTypes) {
160
164
  const name = pending.shift();
161
165
  if (carried.has(name))
162
166
  continue;
167
+ carried.add(name);
163
168
  const text = declarationsByName.get(name);
164
169
  if (text === undefined)
165
170
  continue;
166
- carried.add(name);
167
171
  pending.push(...readReferencedNames(text, 'declaration'));
168
172
  }
169
173
  return carried;
@@ -1,7 +1,7 @@
1
1
  import * as nodePath from 'node:path';
2
2
  import { resolveInlineTypesFile, resolveJobRunFieldIds } from './config.js';
3
3
  import { isControllerFileName, readControllerContract } from './controllerReader.js';
4
- import { CLIENT_INDEX_FILE_NAME, JOBS_INDEX_FILE_NAME, controllerModuleFileName, emitClientIndexModule, emitControllerModule, emitJobModule, emitJobsIndexModule, emitScriptsModule, jobModuleFileName, sortControllers, sortJobs, } from './emit.js';
4
+ import { CLIENT_INDEX_FILE_NAME, JOBS_INDEX_FILE_NAME, controllerModuleFileName, emitClientIndexModule, emitControllerModule, emitJobModule, emitJobsIndexModule, findTypesReachedBy, emitScriptsModule, jobModuleFileName, sortControllers, sortJobs, } from './emit.js';
5
5
  import { jobDefinitionFileName, readJobContract, readJobFolderName } from './jobReader.js';
6
6
  import { toPosixPath } from './file-system.js';
7
7
  import { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
@@ -175,12 +175,12 @@ export function planClientGeneration({ config, fileSystem }) {
175
175
  const jobsDirectory = resolve(config.jobs);
176
176
  const emittedJobs = [];
177
177
  /** Copies what one type import names into the job's sections, following imports between the files it reaches. */
178
- function addInlinedTypes(sections, typeImport, fromLabel) {
178
+ function addInlinedTypes(sections, typeImport, fromLabel, sink) {
179
179
  const file = readInlinable(typeImport.specifier);
180
180
  if (!file || file === 'missing')
181
181
  return;
182
182
  const selected = selectInlinedTypes(file, typeImport.names, fromLabel, readInlinable);
183
- problems.push(...selected.problems);
183
+ sink.push(...selected.problems);
184
184
  for (const selectedSection of selected.sections)
185
185
  addDeclarations(sections, selectedSection.filePath, selectedSection.declarations);
186
186
  }
@@ -223,14 +223,32 @@ export function planClientGeneration({ config, fileSystem }) {
223
223
  problems.push({ filePath: jobLabel, message: `a controller is named '${contract.name}' too; their generated modules would be the same file.` });
224
224
  }
225
225
  const inlinedTypes = [];
226
- // A stage file's own shapes, then whatever it takes from a service: both are the run's, written where its stages are.
227
- for (const stageFile of contract.stageFiles) {
228
- addDeclarations(inlinedTypes, stageFile.filePath, stageFile.declarations);
229
- for (const typeImport of stageFile.inlinedTypeImports)
230
- addInlinedTypes(inlinedTypes, typeImport, stageFile.filePath);
226
+ // What the job's own files declare, then whatever they take from a service: both are the run's shapes.
227
+ const inliningProblems = [];
228
+ for (const folderFile of contract.folderFiles) {
229
+ addDeclarations(inlinedTypes, folderFile.filePath, folderFile.declarations);
230
+ for (const typeImport of folderFile.inlinedTypeImports)
231
+ addInlinedTypes(inlinedTypes, typeImport, folderFile.filePath, inliningProblems);
231
232
  }
232
233
  for (const typeImport of contract.inlinedTypeImports)
233
- addInlinedTypes(inlinedTypes, typeImport, jobLabel);
234
+ addInlinedTypes(inlinedTypes, typeImport, jobLabel, inliningProblems);
235
+ // The result is the only shape of a run the client is given, so it is the only one that has to be
236
+ // carryable. A stage's working types stay on the server: the items a run is planned into may be built
237
+ // on a carrier's API request or a customer's configuration, and none of that is the client's business.
238
+ const carriedByResult = contract.resultType === undefined
239
+ ? new Set()
240
+ : findTypesReachedBy(contract.resultType, contract.typeDeclarations, inlinedTypes);
241
+ // One mistake, reported once: the message names the file the shape lives in, so which stage file
242
+ // imported it adds nothing, and a shape three stages name would otherwise be reported three times.
243
+ const reportedMessages = new Set();
244
+ for (const problem of inliningProblems) {
245
+ if (problem.about !== undefined && !carriedByResult.has(problem.about))
246
+ continue;
247
+ if (reportedMessages.has(problem.message))
248
+ continue;
249
+ reportedMessages.add(problem.message);
250
+ problems.push(problem);
251
+ }
234
252
  problems.push(...findDatesInJobInput(contract, inlinedTypes, jobLabel));
235
253
  emittedJobs.push({ contract, sourceLabel: jobLabel, inlinedTypes });
236
254
  }
@@ -32,10 +32,11 @@ export interface DeclaredJob {
32
32
  parameters: JobParameterContract[];
33
33
  }
34
34
  /**
35
- * A file a stage was read from: what it declares, and the type imports it carries. Its declarations are
36
- * copied into the browser's module like a service's, because a run's shapes are written where its stages are.
35
+ * A file of the job's own folder the reader had to read: a stage, or a file a stage takes a shape from. What
36
+ * it declares is copied into the browser's module like a service's, because a run's shapes are written where
37
+ * its stages are — the value a map stage writes is declared in map.ts and named again in summarize.ts.
37
38
  */
38
- export interface JobStageFile {
39
+ export interface JobFolderFile {
39
40
  filePath: string;
40
41
  declarations: TypeDeclaration[];
41
42
  inlinedTypeImports: InlinedTypeImport[];
@@ -68,8 +69,8 @@ export interface JobContract {
68
69
  stages: JobStageName[];
69
70
  /** The stages the file exports as NetSuite entry points. */
70
71
  exportedStages: string[];
71
- /** The files the stages were imported from, in the order they were read. */
72
- stageFiles: JobStageFile[];
72
+ /** The files of the job's folder that were read, in that order: its stages, and whatever they name. */
73
+ folderFiles: JobFolderFile[];
73
74
  }
74
75
  export interface JobReadResult {
75
76
  contract?: JobContract;
@@ -133,7 +133,7 @@ function readJobDeclaration(declaration, jobName, filePath) {
133
133
  * The exported type declarations of a file and the type imports it carries, for a job's definition file
134
134
  * or for one of its stage files: both end up in the browser's module, so both are read the same way.
135
135
  */
136
- function readFileTypes(sourceFile, filePath, options) {
136
+ function readFileTypes(sourceFile, filePath, options, folderFiles) {
137
137
  const declarations = [];
138
138
  const carriedTypeImports = [];
139
139
  const inlinedTypeImports = [];
@@ -141,7 +141,6 @@ function readFileTypes(sourceFile, filePath, options) {
141
141
  for (const statement of sourceFile.statements) {
142
142
  if (ts.isImportDeclaration(statement)) {
143
143
  const result = readTypeImport(statement, filePath, options);
144
- problems.push(...result.problems);
145
144
  if (result.read?.kind === 'carried')
146
145
  carriedTypeImports.push(result.read.typeImport);
147
146
  else if (result.read?.kind === 'inlined')
@@ -149,6 +148,14 @@ function readFileTypes(sourceFile, filePath, options) {
149
148
  else if (result.read?.kind === 'controller') {
150
149
  problems.push({ filePath, message: "a job does not take types from a controller; its input and result are its own, or a service's." });
151
150
  }
151
+ else if (result.problems.length > 0 && ts.isStringLiteral(statement.moduleSpecifier) && isRelativeSpecifier(statement.moduleSpecifier.text)) {
152
+ // A shape another file of this job's folder declares: read there instead of reported as unreachable.
153
+ const sibling = folderFiles?.read(statement.moduleSpecifier.text);
154
+ if (!sibling)
155
+ problems.push(...result.problems);
156
+ continue;
157
+ }
158
+ problems.push(...result.problems);
152
159
  continue;
153
160
  }
154
161
  if (!ts.isInterfaceDeclaration(statement) && !ts.isTypeAliasDeclaration(statement) && !ts.isEnumDeclaration(statement))
@@ -166,6 +173,35 @@ function readFileTypes(sourceFile, filePath, options) {
166
173
  }
167
174
  return { declarations, carriedTypeImports, inlinedTypeImports, problems };
168
175
  }
176
+ function isRelativeSpecifier(specifier) {
177
+ return specifier.startsWith('./') || specifier.startsWith('../');
178
+ }
179
+ function createJobFolderFiles(options) {
180
+ const collected = [];
181
+ const problems = [];
182
+ const bySpecifier = new Map();
183
+ const folderFiles = {
184
+ collected,
185
+ problems,
186
+ read(specifier) {
187
+ if (bySpecifier.has(specifier))
188
+ return bySpecifier.get(specifier);
189
+ const read = options.readStageFile?.(specifier);
190
+ if (!read) {
191
+ bySpecifier.set(specifier, undefined);
192
+ return undefined;
193
+ }
194
+ const sourceFile = ts.createSourceFile(read.filePath, read.source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
195
+ const entry = { sourceFile, filePath: read.filePath };
196
+ bySpecifier.set(specifier, entry);
197
+ const types = readFileTypes(sourceFile, read.filePath, options, folderFiles);
198
+ problems.push(...types.problems);
199
+ collected.push({ filePath: read.filePath, declarations: types.declarations, inlinedTypeImports: types.inlinedTypeImports, carriedTypeImports: types.carriedTypeImports });
200
+ return entry;
201
+ },
202
+ };
203
+ return folderFiles;
204
+ }
169
205
  /** `import { mapFunction } from './map'`: every value imported by name, by the name this file calls it. */
170
206
  function readValueImports(sourceFile) {
171
207
  const valueImports = new Map();
@@ -200,30 +236,12 @@ function findExportedFunction(sourceFile, name) {
200
236
  }
201
237
  return undefined;
202
238
  }
203
- function readStages(literal, filePath, sourceFile, options) {
239
+ function readStages(literal, filePath, sourceFile, folderFiles) {
204
240
  const problems = [];
205
241
  const stages = [];
206
- const stageFiles = [];
207
242
  const valueImports = readValueImports(sourceFile);
208
- const filesBySpecifier = new Map();
209
243
  let inputType;
210
244
  let resultType;
211
- /** A stage file, read once however many stages come from it, and its shapes collected as it is read. */
212
- function readStageFile(specifier) {
213
- const alreadyRead = filesBySpecifier.get(specifier);
214
- if (alreadyRead)
215
- return alreadyRead;
216
- const read = options.readStageFile?.(specifier);
217
- if (!read)
218
- return undefined;
219
- const stageSourceFile = ts.createSourceFile(read.filePath, read.source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
220
- const types = readFileTypes(stageSourceFile, read.filePath, options);
221
- problems.push(...types.problems);
222
- stageFiles.push({ filePath: read.filePath, declarations: types.declarations, inlinedTypeImports: types.inlinedTypeImports, carriedTypeImports: types.carriedTypeImports });
223
- const entry = { sourceFile: stageSourceFile, filePath: read.filePath };
224
- filesBySpecifier.set(specifier, entry);
225
- return entry;
226
- }
227
245
  /** The function a stage names, in the file it is imported from: `getInputData: getInputDataFunction`. */
228
246
  function findReferencedStage(stageName, referenced) {
229
247
  const imported = valueImports.get(referenced);
@@ -235,7 +253,7 @@ function readStages(literal, filePath, sourceFile, options) {
235
253
  problems.push({ filePath, message: `stage '${stageName}' comes from '${imported.specifier}'; a stage is imported from a file beside the job, such as './${stageName}'.` });
236
254
  return undefined;
237
255
  }
238
- const stageFile = readStageFile(imported.specifier);
256
+ const stageFile = folderFiles.read(imported.specifier);
239
257
  if (!stageFile) {
240
258
  problems.push({ filePath, message: `stage '${stageName}' is imported from '${imported.specifier}', which is not a file beside the job the generator can read.` });
241
259
  return undefined;
@@ -295,7 +313,7 @@ function readStages(literal, filePath, sourceFile, options) {
295
313
  if (!stages.includes('map') && !stages.includes('reduce')) {
296
314
  problems.push({ filePath, message: 'a job declares a map stage, a reduce stage, or both; NetSuite has nothing to run otherwise.' });
297
315
  }
298
- return { stages, inputType, resultType, stageFiles, problems };
316
+ return { stages, inputType, resultType, problems };
299
317
  }
300
318
  export function readJobContract(filePath, source, options) {
301
319
  const problems = [];
@@ -314,7 +332,8 @@ export function readJobContract(filePath, source, options) {
314
332
  if (header !== JOB_SCRIPT_TYPE_HEADER) {
315
333
  problems.push({ filePath, message: `the leading JSDoc says '@NScriptType ${header ?? '(none)'}'; a job is a ${JOB_SCRIPT_TYPE_HEADER}.` });
316
334
  }
317
- const fileTypes = readFileTypes(sourceFile, filePath, options);
335
+ const folderFiles = createJobFolderFiles(options);
336
+ const fileTypes = readFileTypes(sourceFile, filePath, options, folderFiles);
318
337
  problems.push(...fileTypes.problems);
319
338
  const { carriedTypeImports, inlinedTypeImports, declarations: typeDeclarations } = fileTypes;
320
339
  let contract;
@@ -342,7 +361,7 @@ export function readJobContract(filePath, source, options) {
342
361
  }
343
362
  const declared = readJobDeclaration(declarationArgument, name, filePath);
344
363
  problems.push(...declared.problems);
345
- const stages = readStages(stagesArgument, filePath, sourceFile, options);
364
+ const stages = readStages(stagesArgument, filePath, sourceFile, folderFiles);
346
365
  problems.push(...stages.problems);
347
366
  for (const exported of found.exportedStages) {
348
367
  if (!JOB_STAGE_NAMES.includes(exported)) {
@@ -371,9 +390,10 @@ export function readJobContract(filePath, source, options) {
371
390
  resultType: stages.resultType,
372
391
  stages: stages.stages,
373
392
  exportedStages: found.exportedStages,
374
- stageFiles: stages.stageFiles,
393
+ folderFiles: folderFiles.collected,
375
394
  };
376
395
  }
396
+ problems.push(...folderFiles.problems);
377
397
  if (!contract) {
378
398
  if (problems.length === 0) {
379
399
  problems.push({ filePath, message: 'must declare its script: `export const { getInputData, map, summarize } = defineJob({ name, scriptId, deployments, runParameter, runs }, { ... })`.' });
@@ -92,6 +92,7 @@ export function selectInlinedTypes(file, names, controllerPath, resolveImport =
92
92
  else if (imported) {
93
93
  problems.push({
94
94
  filePath: controllerPath,
95
+ about: dependent ?? name,
95
96
  message: dependent
96
97
  ? `type '${dependent}' (${current.filePath}) is built on ${name}, imported from '${importedFrom}' as a rename of ${exportedName}; import it under its own name so the client can carry it.`
97
98
  : `type '${name}' is imported into ${current.filePath} from '${importedFrom}' as a rename of ${exportedName}; import it under its own name so the client can carry it.`,
@@ -100,6 +101,7 @@ export function selectInlinedTypes(file, names, controllerPath, resolveImport =
100
101
  else {
101
102
  problems.push({
102
103
  filePath: controllerPath,
104
+ about: dependent ?? name,
103
105
  message: dependent
104
106
  ? `type '${dependent}' (${current.filePath}) is built on ${name} from '${importedFrom}', which the client cannot carry; write the wire shape out in the controller instead.`
105
107
  : `type '${name}' is imported into ${current.filePath} from '${importedFrom}', not declared there; the client cannot carry it.`,
@@ -107,7 +109,7 @@ export function selectInlinedTypes(file, names, controllerPath, resolveImport =
107
109
  }
108
110
  }
109
111
  else if (dependent === undefined) {
110
- problems.push({ filePath: controllerPath, message: `type '${name}' is not declared in ${current.filePath}.` });
112
+ problems.push({ filePath: controllerPath, about: name, message: `type '${name}' is not declared in ${current.filePath}.` });
111
113
  }
112
114
  // Anything else a declaration refers to is a global (Date, Record, Array): nothing to copy.
113
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amerilux/netsuite-api",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "private": false,
5
5
  "description": "The API layer for a NetSuite single-page app: endpoints served by a Restlet or a Suitelet, a typed browser client, SuiteScript module stubs for tests, and a generator that writes the client module from the controllers.",
6
6
  "license": "MIT",