@amerilux/netsuite-api 0.5.0 → 0.6.1

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
@@ -121,11 +121,16 @@ A hook imports `{ customer }` from it, calls `customer.api.search({ search: 'acm
121
121
  "clientModule": "@amerilux/netsuite-api/client",
122
122
  "wireModule": "@amerilux/netsuite-api",
123
123
  "typeImports": { "@amerilux/netsuite-api/server": "@amerilux/netsuite-api/client" },
124
- "inlineTypes": { "../types/models.gen": "api/src/types/models.gen.ts", "../services/*": "api/src/services/*.ts" }
124
+ "inlineTypes": {
125
+ "../types/models.gen": "api/src/types/models.gen.ts",
126
+ "../services/*": "api/src/services/*.ts",
127
+ "../../types/models.gen": "api/src/types/models.gen.ts",
128
+ "../../services/*": "api/src/services/*.ts"
129
+ }
125
130
  }
126
131
  ```
127
132
 
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.
133
+ 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. The `../../` entries are the same two files named from one folder deeper, because a job is a folder and its stage files sit inside 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.
129
134
 
130
135
  ## The client at runtime
131
136
 
@@ -195,19 +200,21 @@ export const listRolesForEmployee = (employeeId: number) => userRolesApi.byEmplo
195
200
 
196
201
  ## Jobs
197
202
 
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.
203
+ A job is a Map/Reduce script written as stages, and it is a folder: `api/src/jobs/<name>/<name>.ts` declares it and the stages it is made of sit beside it, one file each. 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.
204
+
205
+ The definition file is a declaration and a wiring, so what a job is reads at a glance: its ids, its deployments, its parameters, and which stages it has.
199
206
 
200
207
  ```ts
208
+ // api/src/jobs/closeStaleOrders/closeStaleOrders.ts
201
209
  /**
202
210
  * @NApiVersion 2.1
203
211
  * @NScriptType MapReduceScript
204
212
  */
205
213
  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 }
214
+ import { jobRuns } from '../../scripts.gen';
215
+ import { getInputDataFunction } from './getInputData';
216
+ import { mapFunction } from './map';
217
+ import { summarizeFunction } from './summarize';
211
218
 
212
219
  export const { getInputData, map, summarize } = defineJob({
213
220
  name: 'closeStaleOrders',
@@ -217,21 +224,33 @@ export const { getInputData, map, summarize } = defineJob({
217
224
  parameters: { batchSize: { id: 'custscript_app_close_stale_batch', type: 'integer' } },
218
225
  runs: jobRuns,
219
226
  }, {
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 }),
227
+ getInputData: getInputDataFunction,
228
+ map: mapFunction,
229
+ summarize: summarizeFunction,
225
230
  });
226
231
  ```
227
232
 
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.
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
+
235
+ ```ts
236
+ // api/src/jobs/closeStaleOrders/getInputData.ts
237
+ import { listStaleOrders, type StaleOrder } from '../../services/staleOrderService';
238
+
239
+ /** What a run of this job is asked to do. */
240
+ export interface CloseStaleRequest { olderThanDays: number }
241
+
242
+ export const getInputDataFunction = (input: CloseStaleRequest): StaleOrder[] => listStaleOrders(input.olderThanDays);
243
+ ```
244
+
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 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
+
247
+ 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.
229
248
 
230
249
  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
250
 
232
251
  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
252
 
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.
253
+ 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 passes the job and the input. In the folder shape that decision belongs in the job's own folder (`start.ts`), so everything about a job is one place and a controller reaches for it by name.
235
254
 
236
255
  ```ts
237
256
  // api/src/repositories/jobRunRepository.ts
@@ -27,7 +27,13 @@ export const defaultClientGeneratorConfig = {
27
27
  clientModule: '@amerilux/netsuite-api/client',
28
28
  wireModule: '@amerilux/netsuite-api',
29
29
  typeImports: { '@amerilux/netsuite-api/server': '@amerilux/netsuite-api/client' },
30
- inlineTypes: { '../types/models.gen': 'api/src/types/models.gen.ts', '../services/*': 'api/src/services/*.ts' },
30
+ inlineTypes: {
31
+ '../types/models.gen': 'api/src/types/models.gen.ts',
32
+ '../services/*': 'api/src/services/*.ts',
33
+ // A job is a folder, so its definition and its stage files name the same places from one level deeper.
34
+ '../../types/models.gen': 'api/src/types/models.gen.ts',
35
+ '../../services/*': 'api/src/services/*.ts',
36
+ },
31
37
  };
32
38
  const wildcardInlineTypesKeyPattern = /^([^*]*)\*([^*]*)$/;
33
39
  const wildcardSegmentPattern = /^[A-Za-z0-9_-]+$/;
@@ -8,6 +8,8 @@ export interface FileSystemAdapter {
8
8
  ensureDirectory(directoryPath: string): void;
9
9
  /** Lists the absolute paths of the files directly inside a directory. Returns [] when it does not exist. */
10
10
  listFiles(directoryPath: string): string[];
11
+ /** Lists the absolute paths of the directories directly inside a directory: one per job, under the jobs folder. */
12
+ listDirectories(directoryPath: string): string[];
11
13
  }
12
14
  export declare function toPosixPath(filePath: string): string;
13
15
  export declare function createNodeFileSystemAdapter(): FileSystemAdapter;
@@ -19,6 +19,15 @@ export function createNodeFileSystemAdapter() {
19
19
  .map((entry) => nodePath.join(directoryPath, entry.name))
20
20
  .sort();
21
21
  },
22
+ listDirectories: (directoryPath) => {
23
+ if (!nodeFileSystem.existsSync(directoryPath) || !nodeFileSystem.statSync(directoryPath).isDirectory())
24
+ return [];
25
+ return nodeFileSystem
26
+ .readdirSync(directoryPath, { withFileTypes: true })
27
+ .filter((entry) => entry.isDirectory())
28
+ .map((entry) => nodePath.join(directoryPath, entry.name))
29
+ .sort();
30
+ },
22
31
  };
23
32
  }
24
33
  function normalizeKey(filePath) {
@@ -53,5 +62,19 @@ export function createInMemoryFileSystemAdapter(initialFiles = {}) {
53
62
  .filter((filePath) => filePath.startsWith(prefix) && !filePath.slice(prefix.length).includes('/'))
54
63
  .sort();
55
64
  },
65
+ listDirectories: (directoryPath) => {
66
+ const prefix = `${normalizeKey(directoryPath)}/`;
67
+ const names = new Set();
68
+ for (const filePath of [...files.keys(), ...directories]) {
69
+ if (!filePath.startsWith(prefix))
70
+ continue;
71
+ const [name, ...rest] = filePath.slice(prefix.length).split('/');
72
+ if (name !== undefined && rest.length > 0)
73
+ names.add(name);
74
+ }
75
+ return Array.from(names)
76
+ .sort()
77
+ .map((name) => `${prefix}${name}`);
78
+ },
56
79
  };
57
80
  }
@@ -2,7 +2,7 @@ import * as nodePath from 'node:path';
2
2
  import { resolveInlineTypesFile, resolveJobRunFieldIds } from './config.js';
3
3
  import { isControllerFileName, readControllerContract } from './controllerReader.js';
4
4
  import { CLIENT_INDEX_FILE_NAME, JOBS_INDEX_FILE_NAME, controllerModuleFileName, emitClientIndexModule, emitControllerModule, emitJobModule, emitJobsIndexModule, emitScriptsModule, jobModuleFileName, sortControllers, sortJobs, } from './emit.js';
5
- import { isJobFileName, readJobContract } from './jobReader.js';
5
+ import { jobDefinitionFileName, readJobContract, readJobFolderName } from './jobReader.js';
6
6
  import { toPosixPath } from './file-system.js';
7
7
  import { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
8
8
  import { readReferencedNames } from './wireTypes.js';
@@ -169,16 +169,52 @@ export function planClientGeneration({ config, fileSystem }) {
169
169
  problems.push(...findDatesInRequestShapes(contract, inlinedTypes, controllerLabel));
170
170
  emitted.push({ contract, sourceLabel: controllerLabel, inlinedTypes });
171
171
  }
172
- // Jobs: the same reading, with a run instead of a request and a record instead of a reply.
172
+ // Jobs: the same reading, with a run instead of a request and a record instead of a reply. A job is a
173
+ // folder — <name>/<name>.ts declares it and its stage files sit beside it — so the shapes on either end
174
+ // of a run are read wherever the stage that names them lives.
173
175
  const jobsDirectory = resolve(config.jobs);
174
176
  const emittedJobs = [];
175
- for (const filePath of fileSystem.listFiles(jobsDirectory)) {
176
- const jobLabel = label(filePath);
177
- if (!isJobFileName(nodePath.basename(filePath))) {
178
- problems.push({ filePath: jobLabel, message: 'a job file is named <name>.ts, with <name> in camelCase; nothing else lives in the jobs folder.' });
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) {
179
+ const file = readInlinable(typeImport.specifier);
180
+ if (!file || file === 'missing')
181
+ return;
182
+ const selected = selectInlinedTypes(file, typeImport.names, fromLabel, readInlinable);
183
+ problems.push(...selected.problems);
184
+ for (const selectedSection of selected.sections)
185
+ addDeclarations(sections, selectedSection.filePath, selectedSection.declarations);
186
+ }
187
+ /** One section per source file, so a shape copied twice is written once. */
188
+ function addDeclarations(sections, sourceLabel, declarations) {
189
+ const section = sections.find((existing) => existing.sourceLabel === sourceLabel);
190
+ if (section)
191
+ section.declarations.push(...declarations.filter((declaration) => !section.declarations.some((existing) => existing.name === declaration.name)));
192
+ else
193
+ sections.push({ sourceLabel, declarations: [...declarations] });
194
+ }
195
+ for (const strayPath of fileSystem.listFiles(jobsDirectory)) {
196
+ problems.push({ filePath: label(strayPath), message: 'a job lives in a folder of its own, declared by the file of that name; nothing sits in the jobs folder itself.' });
197
+ }
198
+ for (const jobDirectory of fileSystem.listDirectories(jobsDirectory)) {
199
+ const jobName = readJobFolderName(nodePath.basename(jobDirectory));
200
+ if (jobName === undefined) {
201
+ problems.push({ filePath: label(jobDirectory), message: 'a job folder is named for its job, in camelCase.' });
202
+ continue;
203
+ }
204
+ const definitionPath = nodePath.join(jobDirectory, jobDefinitionFileName(jobName));
205
+ if (!fileSystem.fileExists(definitionPath)) {
206
+ problems.push({ filePath: label(jobDirectory), message: `there is no ${jobDefinitionFileName(jobName)} here; the file of the folder's own name is what declares the job.` });
179
207
  continue;
180
208
  }
181
- const result = readJobContract(jobLabel, fileSystem.readTextFile(filePath), { typeImports: config.typeImports, inlineTypes: config.inlineTypes });
209
+ const jobLabel = label(definitionPath);
210
+ const result = readJobContract(jobLabel, fileSystem.readTextFile(definitionPath), {
211
+ typeImports: config.typeImports,
212
+ inlineTypes: config.inlineTypes,
213
+ readStageFile: (specifier) => {
214
+ const stagePath = `${nodePath.resolve(jobDirectory, specifier)}.ts`;
215
+ return fileSystem.fileExists(stagePath) ? { filePath: label(stagePath), source: fileSystem.readTextFile(stagePath) } : undefined;
216
+ },
217
+ });
182
218
  problems.push(...result.problems);
183
219
  const contract = result.contract;
184
220
  if (!contract)
@@ -187,20 +223,14 @@ export function planClientGeneration({ config, fileSystem }) {
187
223
  problems.push({ filePath: jobLabel, message: `a controller is named '${contract.name}' too; their generated modules would be the same file.` });
188
224
  }
189
225
  const inlinedTypes = [];
190
- for (const typeImport of contract.inlinedTypeImports) {
191
- const file = readInlinable(typeImport.specifier);
192
- if (!file || file === 'missing')
193
- continue;
194
- const selected = selectInlinedTypes(file, typeImport.names, jobLabel, readInlinable);
195
- problems.push(...selected.problems);
196
- for (const selectedSection of selected.sections) {
197
- const section = inlinedTypes.find((existing) => existing.sourceLabel === selectedSection.filePath);
198
- if (section)
199
- section.declarations.push(...selectedSection.declarations.filter((declaration) => !section.declarations.some((existing) => existing.name === declaration.name)));
200
- else
201
- inlinedTypes.push({ sourceLabel: selectedSection.filePath, declarations: selectedSection.declarations });
202
- }
226
+ // What the job's own files declare, then whatever they take from a service: both are the run's shapes.
227
+ for (const folderFile of contract.folderFiles) {
228
+ addDeclarations(inlinedTypes, folderFile.filePath, folderFile.declarations);
229
+ for (const typeImport of folderFile.inlinedTypeImports)
230
+ addInlinedTypes(inlinedTypes, typeImport, folderFile.filePath);
203
231
  }
232
+ for (const typeImport of contract.inlinedTypeImports)
233
+ addInlinedTypes(inlinedTypes, typeImport, jobLabel);
204
234
  problems.push(...findDatesInJobInput(contract, inlinedTypes, jobLabel));
205
235
  emittedJobs.push({ contract, sourceLabel: jobLabel, inlinedTypes });
206
236
  }
@@ -4,7 +4,7 @@ export { JOB_RUN_FIELD_SUFFIXES, netsuiteValueTypeNames, resolveJobRunFieldIds }
4
4
  export type { ClientGeneratorConfig, JobRunFieldName, JobRunsSettings, NetsuiteValueTypeName, ResolvedClientGeneratorConfig } from './config.js';
5
5
  export { GENERATED_CLIENT_NAME, GENERATED_ENDPOINTS_TYPE_NAME, RAW_RESPONSE_TYPE_NAME, isControllerFileName, readControllerContract, readLeadingJsDoc } from './controllerReader.js';
6
6
  export type { CarriedTypeImport, ControllerContract, ControllerKind, ControllerProblem, ControllerReadResult, ControllerTypeImport, DeclaredScript, EndpointSignature, InlinedTypeImport, ReadControllerOptions, TypeDeclaration, TypeImportName } from './controllerReader.js';
7
- export { GENERATED_JOB_RESULT_TYPE_NAME, JOB_SCRIPT_TYPE_HEADER, JOB_STAGE_NAMES, isJobFileName, readJobContract } from './jobReader.js';
7
+ export { GENERATED_JOB_RESULT_TYPE_NAME, JOB_SCRIPT_TYPE_HEADER, JOB_STAGE_NAMES, jobDefinitionFileName, readJobContract, readJobFolderName } from './jobReader.js';
8
8
  export type { DeclaredJob, JobContract, JobParameterContract, JobReadResult, JobStageName } from './jobReader.js';
9
9
  export { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
10
10
  export type { InlinableTypeDeclaration, InlinableTypesFile, SelectedInlinedTypes } from './typesFileReader.js';
@@ -2,7 +2,7 @@
2
2
  export { DEFAULT_CONFIG_FILE_NAME, ClientGeneratorConfigError, defaultClientGeneratorConfig, loadClientGeneratorConfig } from './config.js';
3
3
  export { JOB_RUN_FIELD_SUFFIXES, netsuiteValueTypeNames, resolveJobRunFieldIds } from './config.js';
4
4
  export { GENERATED_CLIENT_NAME, GENERATED_ENDPOINTS_TYPE_NAME, RAW_RESPONSE_TYPE_NAME, isControllerFileName, readControllerContract, readLeadingJsDoc } from './controllerReader.js';
5
- export { GENERATED_JOB_RESULT_TYPE_NAME, JOB_SCRIPT_TYPE_HEADER, JOB_STAGE_NAMES, isJobFileName, readJobContract } from './jobReader.js';
5
+ export { GENERATED_JOB_RESULT_TYPE_NAME, JOB_SCRIPT_TYPE_HEADER, JOB_STAGE_NAMES, jobDefinitionFileName, readJobContract, readJobFolderName } from './jobReader.js';
6
6
  export { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
7
7
  export { CLIENT_INDEX_FILE_NAME, JOBS_INDEX_FILE_NAME, controllerModuleFileName, emitClientIndexModule, emitControllerModule, emitJobModule, emitJobsIndexModule, emitScriptsModule, jobModuleFileName } from './emit.js';
8
8
  export { checkClientGeneration, planClientGeneration, runClientGeneration } from './generate.js';
@@ -1,10 +1,14 @@
1
1
  import type { CarriedTypeImport, ControllerProblem, InlinedTypeImport, ReadControllerOptions, TypeDeclaration } from './controllerReader.js';
2
2
  /**
3
- * Reads what a job run carries, from its source alone: the script it declares in its defineJob call,
4
- * the stages it has, and the types on either end of a run. A run's input is the first parameter of
5
- * getInputData and its result is what summarize returns, so those two annotations are the contract,
6
- * the way a handler's parameter and return type are a controller's. A parse, not a type check: the
7
- * ids are string literals and the shapes are written out.
3
+ * Reads what a job run carries: the script it declares in its defineJob call, the stages it has, and
4
+ * the types on either end of a run. A run's input is the first parameter of getInputData and its result
5
+ * is what summarize returns, so those two annotations are the contract, the way a handler's parameter
6
+ * and return type are a controller's. A parse, not a type check: the ids are string literals and the
7
+ * shapes are written out.
8
+ *
9
+ * A job is a folder — `<name>/<name>.ts` declares it — and a stage is either a function written inline
10
+ * or one imported from a file beside it (`./map`), which is where the annotations are then read from. So
11
+ * a developer opens map.ts to see what the map stage does, and the definition file stays a declaration.
8
12
  */
9
13
  /** The stage names a job may declare, in the order NetSuite calls them. */
10
14
  export declare const JOB_STAGE_NAMES: readonly ["getInputData", "map", "reduce", "summarize"];
@@ -27,6 +31,28 @@ export interface DeclaredJob {
27
31
  runParameter: string;
28
32
  parameters: JobParameterContract[];
29
33
  }
34
+ /**
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.
38
+ */
39
+ export interface JobFolderFile {
40
+ filePath: string;
41
+ declarations: TypeDeclaration[];
42
+ inlinedTypeImports: InlinedTypeImport[];
43
+ carriedTypeImports: CarriedTypeImport[];
44
+ }
45
+ /** What a job's reader needs beyond a controller's: the files its stages are imported from. */
46
+ export interface ReadJobOptions extends ReadControllerOptions {
47
+ /**
48
+ * Reads a file a stage is imported from, the specifier resolved against the job's own folder, or
49
+ * undefined when there is no such file. Without it, only inline stages can be read.
50
+ */
51
+ readStageFile?(specifier: string): {
52
+ filePath: string;
53
+ source: string;
54
+ } | undefined;
55
+ }
30
56
  export interface JobContract {
31
57
  /** The job's name: `closeStaleOrders` for closeStaleOrders.ts, matching `name` in its declaration. */
32
58
  name: string;
@@ -43,11 +69,16 @@ export interface JobContract {
43
69
  stages: JobStageName[];
44
70
  /** The stages the file exports as NetSuite entry points. */
45
71
  exportedStages: string[];
72
+ /** The files of the job's folder that were read, in that order: its stages, and whatever they name. */
73
+ folderFiles: JobFolderFile[];
46
74
  }
47
75
  export interface JobReadResult {
48
76
  contract?: JobContract;
49
77
  problems: ControllerProblem[];
50
78
  }
51
- export declare function isJobFileName(fileName: string): boolean;
52
- export declare function readJobContract(filePath: string, source: string, options: ReadControllerOptions): JobReadResult;
79
+ /** The name a job folder declares: `approveOrders` for api/src/jobs/approveOrders, or undefined when it is not a name. */
80
+ export declare function readJobFolderName(folderName: string): string | undefined;
81
+ /** The file that declares the job of a folder: the folder's own name again, so the file is unique to open and to search for. */
82
+ export declare function jobDefinitionFileName(jobName: string): string;
83
+ export declare function readJobContract(filePath: string, source: string, options: ReadJobOptions): JobReadResult;
53
84
  export {};
@@ -3,11 +3,15 @@ import ts from 'typescript';
3
3
  import { hasExportModifier, readLeadingJsDoc, readScriptTypeHeader, readStringProperty, readTypeImport } from './controllerReader.js';
4
4
  import { toPosixPath } from './file-system.js';
5
5
  /**
6
- * Reads what a job run carries, from its source alone: the script it declares in its defineJob call,
7
- * the stages it has, and the types on either end of a run. A run's input is the first parameter of
8
- * getInputData and its result is what summarize returns, so those two annotations are the contract,
9
- * the way a handler's parameter and return type are a controller's. A parse, not a type check: the
10
- * ids are string literals and the shapes are written out.
6
+ * Reads what a job run carries: the script it declares in its defineJob call, the stages it has, and
7
+ * the types on either end of a run. A run's input is the first parameter of getInputData and its result
8
+ * is what summarize returns, so those two annotations are the contract, the way a handler's parameter
9
+ * and return type are a controller's. A parse, not a type check: the ids are string literals and the
10
+ * shapes are written out.
11
+ *
12
+ * A job is a folder — `<name>/<name>.ts` declares it — and a stage is either a function written inline
13
+ * or one imported from a file beside it (`./map`), which is where the annotations are then read from. So
14
+ * a developer opens map.ts to see what the map stage does, and the definition file stays a declaration.
11
15
  */
12
16
  /** The stage names a job may declare, in the order NetSuite calls them. */
13
17
  export const JOB_STAGE_NAMES = ['getInputData', 'map', 'reduce', 'summarize'];
@@ -16,9 +20,15 @@ export const JOB_SCRIPT_TYPE_HEADER = 'MapReduceScript';
16
20
  /** The type the generated job module gives the run's input, and the one it gives the result. */
17
21
  export const GENERATED_JOB_RESULT_TYPE_NAME = 'Result';
18
22
  const jobFileNamePattern = /^([a-z][A-Za-z0-9]*)\.ts$/;
23
+ const jobFolderNamePattern = /^[a-z][A-Za-z0-9]*$/;
19
24
  const netsuiteValueTypes = ['text', 'integer', 'decimal', 'checkbox', 'date', 'select'];
20
- export function isJobFileName(fileName) {
21
- return jobFileNamePattern.test(fileName) && !fileName.endsWith('.d.ts');
25
+ /** The name a job folder declares: `approveOrders` for api/src/jobs/approveOrders, or undefined when it is not a name. */
26
+ export function readJobFolderName(folderName) {
27
+ return jobFolderNamePattern.test(folderName) ? folderName : undefined;
28
+ }
29
+ /** The file that declares the job of a folder: the folder's own name again, so the file is unique to open and to search for. */
30
+ export function jobDefinitionFileName(jobName) {
31
+ return `${jobName}.ts`;
22
32
  }
23
33
  /** `export const { getInputData, map, summarize } = defineJob(...)`: the call, and the names it exports. */
24
34
  function findDefineJobCall(statement) {
@@ -98,7 +108,7 @@ function readJobDeclaration(declaration, jobName, filePath) {
98
108
  if (name === undefined)
99
109
  problems.push({ filePath, message: "the job declaration needs 'name' as a string literal; the generator reads it from the source." });
100
110
  else if (name !== jobName)
101
- problems.push({ filePath, message: `the job declaration says name: '${name}' but the file is ${jobName}.ts; the name is the file name.` });
111
+ problems.push({ filePath, message: `the job declaration says name: '${name}' but the job is ${jobName}; the name is the job's folder and file name.` });
102
112
  if (scriptId === undefined)
103
113
  problems.push({ filePath, message: "the job declaration needs 'scriptId' as a string literal." });
104
114
  if (runParameter === undefined) {
@@ -119,38 +129,183 @@ function readJobDeclaration(declaration, jobName, filePath) {
119
129
  return { problems };
120
130
  return { script: { scriptId, deployments, runParameter, parameters: readParametersResult.parameters }, problems };
121
131
  }
122
- function readStages(literal, filePath, sourceFile) {
132
+ /**
133
+ * The exported type declarations of a file and the type imports it carries, for a job's definition file
134
+ * or for one of its stage files: both end up in the browser's module, so both are read the same way.
135
+ */
136
+ function readFileTypes(sourceFile, filePath, options, folderFiles) {
137
+ const declarations = [];
138
+ const carriedTypeImports = [];
139
+ const inlinedTypeImports = [];
140
+ const problems = [];
141
+ for (const statement of sourceFile.statements) {
142
+ if (ts.isImportDeclaration(statement)) {
143
+ const result = readTypeImport(statement, filePath, options);
144
+ if (result.read?.kind === 'carried')
145
+ carriedTypeImports.push(result.read.typeImport);
146
+ else if (result.read?.kind === 'inlined')
147
+ inlinedTypeImports.push(result.read.typeImport);
148
+ else if (result.read?.kind === 'controller') {
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." });
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);
159
+ continue;
160
+ }
161
+ if (!ts.isInterfaceDeclaration(statement) && !ts.isTypeAliasDeclaration(statement) && !ts.isEnumDeclaration(statement))
162
+ continue;
163
+ if (!hasExportModifier(statement)) {
164
+ problems.push({ filePath, message: `'${statement.name.text}' is not exported; the generator copies the shapes a run's result names into the browser's module as they are written, so export them.` });
165
+ continue;
166
+ }
167
+ if (statement.name.text === GENERATED_JOB_RESULT_TYPE_NAME) {
168
+ problems.push({ filePath, message: `type '${statement.name.text}' is the name the generated module gives the run's result; call this shape something else.` });
169
+ continue;
170
+ }
171
+ const jsDoc = readLeadingJsDoc(statement, sourceFile);
172
+ declarations.push({ name: statement.name.text, text: `${jsDoc ? `${jsDoc}\n` : ''}${statement.getText(sourceFile)}` });
173
+ }
174
+ return { declarations, carriedTypeImports, inlinedTypeImports, problems };
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
+ }
205
+ /** `import { mapFunction } from './map'`: every value imported by name, by the name this file calls it. */
206
+ function readValueImports(sourceFile) {
207
+ const valueImports = new Map();
208
+ for (const statement of sourceFile.statements) {
209
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
210
+ continue;
211
+ const clause = statement.importClause;
212
+ if (!clause || clause.isTypeOnly || !clause.namedBindings || !ts.isNamedImports(clause.namedBindings))
213
+ continue;
214
+ for (const element of clause.namedBindings.elements) {
215
+ if (element.isTypeOnly)
216
+ continue;
217
+ valueImports.set(element.name.text, { specifier: statement.moduleSpecifier.text, importedName: (element.propertyName ?? element.name).text });
218
+ }
219
+ }
220
+ return valueImports;
221
+ }
222
+ /** The exported function of that name in a stage file: `export const mapFunction = …` or `export function mapFunction…`. */
223
+ function findExportedFunction(sourceFile, name) {
224
+ for (const statement of sourceFile.statements) {
225
+ if (ts.isFunctionDeclaration(statement) && hasExportModifier(statement) && statement.name?.text === name)
226
+ return statement;
227
+ if (!ts.isVariableStatement(statement) || !hasExportModifier(statement))
228
+ continue;
229
+ for (const declaration of statement.declarationList.declarations) {
230
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name)
231
+ continue;
232
+ const initializer = declaration.initializer;
233
+ if (initializer && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)))
234
+ return initializer;
235
+ }
236
+ }
237
+ return undefined;
238
+ }
239
+ function readStages(literal, filePath, sourceFile, folderFiles) {
123
240
  const problems = [];
124
241
  const stages = [];
242
+ const valueImports = readValueImports(sourceFile);
125
243
  let inputType;
126
244
  let resultType;
245
+ /** The function a stage names, in the file it is imported from: `getInputData: getInputDataFunction`. */
246
+ function findReferencedStage(stageName, referenced) {
247
+ const imported = valueImports.get(referenced);
248
+ if (!imported) {
249
+ problems.push({ filePath, message: `stage '${stageName}' names '${referenced}', which this file does not import; a stage is written inline or imported from a file beside the job.` });
250
+ return undefined;
251
+ }
252
+ if (!imported.specifier.startsWith('./') && !imported.specifier.startsWith('../')) {
253
+ problems.push({ filePath, message: `stage '${stageName}' comes from '${imported.specifier}'; a stage is imported from a file beside the job, such as './${stageName}'.` });
254
+ return undefined;
255
+ }
256
+ const stageFile = folderFiles.read(imported.specifier);
257
+ if (!stageFile) {
258
+ problems.push({ filePath, message: `stage '${stageName}' is imported from '${imported.specifier}', which is not a file beside the job the generator can read.` });
259
+ return undefined;
260
+ }
261
+ const handler = findExportedFunction(stageFile.sourceFile, imported.importedName);
262
+ if (!handler) {
263
+ problems.push({ filePath: stageFile.filePath, message: `'${imported.importedName}' is not exported from here as a function, and stage '${stageName}' names it; its annotations are the run's contract.` });
264
+ return undefined;
265
+ }
266
+ return { handler, sourceFile: stageFile.sourceFile, filePath: stageFile.filePath };
267
+ }
127
268
  for (const property of literal.properties) {
128
269
  const stageName = property.name && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) ? property.name.text : undefined;
129
270
  if (stageName === undefined || !JOB_STAGE_NAMES.includes(stageName)) {
130
271
  problems.push({ filePath, message: `'${stageName ?? 'a stage'}' is not a stage; a job declares ${JOB_STAGE_NAMES.join(', ')}.` });
131
272
  continue;
132
273
  }
274
+ // Where the annotations are read from: the definition file for an inline stage, the stage's own file otherwise.
133
275
  let handler;
276
+ let annotatedIn = { sourceFile, filePath };
134
277
  if (ts.isMethodDeclaration(property))
135
278
  handler = property;
136
279
  else if (ts.isPropertyAssignment(property) && (ts.isArrowFunction(property.initializer) || ts.isFunctionExpression(property.initializer)))
137
280
  handler = property.initializer;
138
- if (!handler) {
139
- problems.push({ filePath, message: `stage '${stageName}' must be an inline function; a reference to a function elsewhere carries no types the generator can read.` });
140
- continue;
281
+ else {
282
+ const referenced = ts.isShorthandPropertyAssignment(property)
283
+ ? property.name.text
284
+ : ts.isPropertyAssignment(property) && ts.isIdentifier(property.initializer)
285
+ ? property.initializer.text
286
+ : undefined;
287
+ if (referenced === undefined) {
288
+ problems.push({ filePath, message: `stage '${stageName}' is neither a function nor the name of one; write it inline or import it from a file beside the job.` });
289
+ continue;
290
+ }
291
+ const found = findReferencedStage(stageName, referenced);
292
+ if (!found)
293
+ continue;
294
+ handler = found.handler;
295
+ annotatedIn = { sourceFile: found.sourceFile, filePath: found.filePath };
141
296
  }
142
297
  stages.push(stageName);
143
298
  if (stageName === 'getInputData') {
144
299
  const parameter = handler.parameters[0];
145
300
  if (parameter && !parameter.type) {
146
- problems.push({ filePath, message: "getInputData has no type on its first parameter; a run's input shape is read from it." });
301
+ problems.push({ filePath: annotatedIn.filePath, message: "getInputData has no type on its first parameter; a run's input shape is read from it." });
147
302
  }
148
- inputType = parameter?.type?.getText(sourceFile);
303
+ inputType = parameter?.type?.getText(annotatedIn.sourceFile);
149
304
  }
150
305
  if (stageName === 'summarize') {
151
306
  if (!handler.type)
152
- problems.push({ filePath, message: "summarize has no return type annotation; a run's result shape is read from it." });
153
- resultType = handler.type?.getText(sourceFile);
307
+ problems.push({ filePath: annotatedIn.filePath, message: "summarize has no return type annotation; a run's result shape is read from it." });
308
+ resultType = handler.type?.getText(annotatedIn.sourceFile);
154
309
  }
155
310
  }
156
311
  if (!stages.includes('getInputData'))
@@ -162,46 +317,27 @@ function readStages(literal, filePath, sourceFile) {
162
317
  }
163
318
  export function readJobContract(filePath, source, options) {
164
319
  const problems = [];
165
- const fileName = nodePath.basename(toPosixPath(filePath));
320
+ const posixPath = toPosixPath(filePath);
321
+ const fileName = nodePath.basename(posixPath);
166
322
  const nameMatch = jobFileNamePattern.exec(fileName);
167
323
  if (!nameMatch)
168
- return { problems: [{ filePath, message: 'a job file is named <name>.ts, with <name> in camelCase; nothing else lives in the jobs folder.' }] };
324
+ return { problems: [{ filePath, message: 'a job is declared in <name>.ts, with <name> in camelCase; nothing else declares a job.' }] };
169
325
  const name = nameMatch[1];
326
+ const folderName = nodePath.basename(nodePath.dirname(posixPath));
327
+ if (folderName !== name) {
328
+ problems.push({ filePath, message: `a job lives in a folder of its own name: ${fileName} belongs in a folder called ${name}, beside the stage files it is made of.` });
329
+ }
170
330
  const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
171
331
  const header = readScriptTypeHeader(source);
172
332
  if (header !== JOB_SCRIPT_TYPE_HEADER) {
173
333
  problems.push({ filePath, message: `the leading JSDoc says '@NScriptType ${header ?? '(none)'}'; a job is a ${JOB_SCRIPT_TYPE_HEADER}.` });
174
334
  }
175
- const carriedTypeImports = [];
176
- const inlinedTypeImports = [];
177
- const typeDeclarations = [];
335
+ const folderFiles = createJobFolderFiles(options);
336
+ const fileTypes = readFileTypes(sourceFile, filePath, options, folderFiles);
337
+ problems.push(...fileTypes.problems);
338
+ const { carriedTypeImports, inlinedTypeImports, declarations: typeDeclarations } = fileTypes;
178
339
  let contract;
179
340
  for (const statement of sourceFile.statements) {
180
- if (ts.isImportDeclaration(statement)) {
181
- const result = readTypeImport(statement, filePath, options);
182
- problems.push(...result.problems);
183
- if (result.read?.kind === 'carried')
184
- carriedTypeImports.push(result.read.typeImport);
185
- else if (result.read?.kind === 'inlined')
186
- inlinedTypeImports.push(result.read.typeImport);
187
- else if (result.read?.kind === 'controller') {
188
- problems.push({ filePath, message: 'a job does not take types from a controller; its input and result are its own, or a service\'s.' });
189
- }
190
- continue;
191
- }
192
- if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement)) {
193
- if (!hasExportModifier(statement)) {
194
- problems.push({ filePath, message: `'${statement.name.text}' is not exported; the generator copies the shapes a run's result names into the browser's module as they are written, so export them.` });
195
- continue;
196
- }
197
- if (statement.name.text === GENERATED_JOB_RESULT_TYPE_NAME) {
198
- problems.push({ filePath, message: `type '${statement.name.text}' is the name the generated module gives the run's result; call this shape something else.` });
199
- continue;
200
- }
201
- const jsDoc = readLeadingJsDoc(statement, sourceFile);
202
- typeDeclarations.push({ name: statement.name.text, text: `${jsDoc ? `${jsDoc}\n` : ''}${statement.getText(sourceFile)}` });
203
- continue;
204
- }
205
341
  if (!ts.isVariableStatement(statement))
206
342
  continue;
207
343
  const found = findDefineJobCall(statement);
@@ -225,7 +361,7 @@ export function readJobContract(filePath, source, options) {
225
361
  }
226
362
  const declared = readJobDeclaration(declarationArgument, name, filePath);
227
363
  problems.push(...declared.problems);
228
- const stages = readStages(stagesArgument, filePath, sourceFile);
364
+ const stages = readStages(stagesArgument, filePath, sourceFile, folderFiles);
229
365
  problems.push(...stages.problems);
230
366
  for (const exported of found.exportedStages) {
231
367
  if (!JOB_STAGE_NAMES.includes(exported)) {
@@ -246,8 +382,18 @@ export function readJobContract(filePath, source, options) {
246
382
  }
247
383
  if (!declared.script)
248
384
  continue;
249
- contract = { name, filePath, script: declared.script, inputType: stages.inputType, resultType: stages.resultType, stages: stages.stages, exportedStages: found.exportedStages };
385
+ contract = {
386
+ name,
387
+ filePath,
388
+ script: declared.script,
389
+ inputType: stages.inputType,
390
+ resultType: stages.resultType,
391
+ stages: stages.stages,
392
+ exportedStages: found.exportedStages,
393
+ folderFiles: folderFiles.collected,
394
+ };
250
395
  }
396
+ problems.push(...folderFiles.problems);
251
397
  if (!contract) {
252
398
  if (problems.length === 0) {
253
399
  problems.push({ filePath, message: 'must declare its script: `export const { getInputData, map, summarize } = defineJob({ name, scriptId, deployments, runParameter, runs }, { ... })`.' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amerilux/netsuite-api",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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",