@amerilux/netsuite-api 0.4.0 → 0.6.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.
@@ -1,7 +1,8 @@
1
1
  import * as nodePath from 'node:path';
2
- import { resolveInlineTypesFile } from './config.js';
2
+ import { resolveInlineTypesFile, resolveJobRunFieldIds } from './config.js';
3
3
  import { isControllerFileName, readControllerContract } from './controllerReader.js';
4
- import { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitClientIndexModule, emitControllerModule, emitScriptsModule, sortControllers } from './emit.js';
4
+ import { CLIENT_INDEX_FILE_NAME, JOBS_INDEX_FILE_NAME, controllerModuleFileName, emitClientIndexModule, emitControllerModule, emitJobModule, emitJobsIndexModule, emitScriptsModule, jobModuleFileName, sortControllers, sortJobs, } from './emit.js';
5
+ import { jobDefinitionFileName, readJobContract, readJobFolderName } from './jobReader.js';
5
6
  import { toPosixPath } from './file-system.js';
6
7
  import { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
7
8
  import { readReferencedNames } from './wireTypes.js';
@@ -9,35 +10,71 @@ const generatedFileNamePattern = /\.gen\.ts$/;
9
10
  function relativeLabel(rootDirectory, filePath) {
10
11
  return toPosixPath(nodePath.relative(rootDirectory, filePath));
11
12
  }
12
- function findDuplicateScriptIds(controllers) {
13
+ function findDuplicateScriptIds(controllers, jobs) {
13
14
  const problems = [];
14
- for (const property of ['scriptId', 'deployId']) {
15
- const owners = new Map();
16
- for (const { contract } of controllers) {
17
- const id = contract.script[property];
18
- const owner = owners.get(id);
19
- if (owner !== undefined)
20
- problems.push({ filePath: contract.filePath, message: `${property} '${id}' is also declared by ${owner}; every controller is its own script.` });
21
- else
22
- owners.set(id, contract.filePath);
23
- }
15
+ const scriptOwners = new Map();
16
+ const deployOwners = new Map();
17
+ const claim = (owners, id, filePath, label) => {
18
+ const owner = owners.get(id);
19
+ if (owner !== undefined)
20
+ problems.push({ filePath, message: `${label} '${id}' is also declared by ${owner}; every controller and every job is its own script.` });
21
+ else
22
+ owners.set(id, filePath);
23
+ };
24
+ for (const { contract } of controllers) {
25
+ claim(scriptOwners, contract.script.scriptId, contract.filePath, 'scriptId');
26
+ claim(deployOwners, contract.script.deployId, contract.filePath, 'deployId');
27
+ }
28
+ for (const { contract } of jobs) {
29
+ claim(scriptOwners, contract.script.scriptId, contract.filePath, 'scriptId');
30
+ for (const deployment of contract.script.deployments)
31
+ claim(deployOwners, deployment, contract.filePath, 'deployment');
24
32
  }
25
33
  return problems;
26
34
  }
27
- /**
28
- * A Date reached from a request shape, through the controller's own declarations and the copied
29
- * ones: the handler would receive an ISO string where its annotation promises a Date. (A shape taken
30
- * from a sibling controller is checked where it is declared, as that controller's own request.)
31
- */
32
- function findDatesInRequestShapes(contract, inlinedTypes, controllerLabel) {
35
+ /** Every type declaration a shape can be built from: the file's own, then the ones copied into it. */
36
+ function collectDeclarations(own, inlinedTypes) {
33
37
  const declarations = new Map();
34
- for (const declaration of contract.typeDeclarations)
38
+ for (const declaration of own)
35
39
  declarations.set(declaration.name, declaration.text);
36
40
  for (const section of inlinedTypes) {
37
41
  for (const declaration of section.declarations)
38
42
  if (!declarations.has(declaration.name))
39
43
  declarations.set(declaration.name, declaration.text);
40
44
  }
45
+ return declarations;
46
+ }
47
+ /**
48
+ * A Date reached from a run's input: it is stored as JSON on the run record, so the stage would
49
+ * receive an ISO string where its annotation promises a Date. The result is not checked, because
50
+ * nothing reads it back as a Date; the browser sees whatever summarize wrote.
51
+ */
52
+ function findDatesInJobInput(contract, inlinedTypes, jobLabel) {
53
+ if (contract.inputType === undefined)
54
+ return [];
55
+ const declarations = collectDeclarations(contract.typeDeclarations, inlinedTypes);
56
+ const visited = new Set();
57
+ const pending = readReferencedNames(contract.inputType, 'type').map((name) => ({ name, via: contract.inputType }));
58
+ while (pending.length > 0) {
59
+ const { name, via } = pending.shift();
60
+ if (name === 'Date') {
61
+ return [{ filePath: jobLabel, message: `getInputData takes a Date in its input (through ${via}); a run carries its input as JSON, so take a string and parse it in the stage.` }];
62
+ }
63
+ const text = declarations.get(name);
64
+ if (text === undefined || visited.has(name))
65
+ continue;
66
+ visited.add(name);
67
+ pending.push(...readReferencedNames(text, 'declaration').map((reference) => ({ name: reference, via: name })));
68
+ }
69
+ return [];
70
+ }
71
+ /**
72
+ * A Date reached from a request shape, through the controller's own declarations and the copied
73
+ * ones: the handler would receive an ISO string where its annotation promises a Date. (A shape taken
74
+ * from a sibling controller is checked where it is declared, as that controller's own request.)
75
+ */
76
+ function findDatesInRequestShapes(contract, inlinedTypes, controllerLabel) {
77
+ const declarations = collectDeclarations(contract.typeDeclarations, inlinedTypes);
41
78
  const problems = [];
42
79
  for (const endpoint of contract.endpoints) {
43
80
  if (endpoint.requestType === undefined)
@@ -132,7 +169,78 @@ export function planClientGeneration({ config, fileSystem }) {
132
169
  problems.push(...findDatesInRequestShapes(contract, inlinedTypes, controllerLabel));
133
170
  emitted.push({ contract, sourceLabel: controllerLabel, inlinedTypes });
134
171
  }
135
- problems.push(...findDuplicateScriptIds(emitted));
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.
175
+ const jobsDirectory = resolve(config.jobs);
176
+ const emittedJobs = [];
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.` });
207
+ continue;
208
+ }
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
+ });
218
+ problems.push(...result.problems);
219
+ const contract = result.contract;
220
+ if (!contract)
221
+ continue;
222
+ if (controllerNames.has(contract.name) && jobModuleFileName(contract.name) === controllerModuleFileName(contract.name)) {
223
+ problems.push({ filePath: jobLabel, message: `a controller is named '${contract.name}' too; their generated modules would be the same file.` });
224
+ }
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);
231
+ }
232
+ for (const typeImport of contract.inlinedTypeImports)
233
+ addInlinedTypes(inlinedTypes, typeImport, jobLabel);
234
+ problems.push(...findDatesInJobInput(contract, inlinedTypes, jobLabel));
235
+ emittedJobs.push({ contract, sourceLabel: jobLabel, inlinedTypes });
236
+ }
237
+ if (emittedJobs.length > 0 && config.jobRuns === undefined) {
238
+ problems.push({
239
+ filePath: label(jobsDirectory),
240
+ message: 'the project has jobs but no run record: add the `jobRuns` block to netsuite-api.config.json (`npm run add:jobs` writes it, with the record itself).',
241
+ });
242
+ }
243
+ problems.push(...findDuplicateScriptIds(emitted, emittedJobs));
136
244
  const controllers = emitted.map(({ contract }) => ({
137
245
  name: contract.name,
138
246
  filePath: contract.filePath,
@@ -140,19 +248,31 @@ export function planClientGeneration({ config, fileSystem }) {
140
248
  browser: contract.script.browser,
141
249
  endpointCount: contract.endpoints.length,
142
250
  }));
251
+ const jobs = emittedJobs.map(({ contract }) => ({
252
+ name: contract.name,
253
+ filePath: contract.filePath,
254
+ stages: contract.stages,
255
+ deploymentCount: contract.script.deployments.length,
256
+ }));
143
257
  if (problems.length > 0)
144
- return { files: [], leftoverFiles: [], controllers, problems };
258
+ return { files: [], leftoverFiles: [], controllers, jobs, problems };
145
259
  const controllersLabel = label(controllersDirectory);
260
+ const jobsLabel = label(jobsDirectory);
261
+ const jobRuns = config.jobRuns
262
+ ? { recordType: config.jobRuns.recordType, fields: resolveJobRunFieldIds(config.jobRuns), extraFields: config.jobRuns.extraFields ?? {} }
263
+ : undefined;
146
264
  const files = [
147
265
  ...sortControllers(emitted).map((controller) => ({
148
266
  path: nodePath.join(outDirectory, controllerModuleFileName(controller.contract.name)),
149
267
  content: emitControllerModule(controller, { clientModule: config.clientModule }),
150
268
  })),
151
- { path: nodePath.join(outDirectory, CLIENT_INDEX_FILE_NAME), content: emitClientIndexModule(emitted, { controllersLabel }) },
152
- { path: resolve(config.scriptsOutFile), content: emitScriptsModule(emitted, { wireModule: config.wireModule, controllersLabel }) },
269
+ ...sortJobs(emittedJobs).map((job) => ({ path: nodePath.join(outDirectory, jobModuleFileName(job.contract.name)), content: emitJobModule(job) })),
270
+ ...(emittedJobs.length > 0 ? [{ path: nodePath.join(outDirectory, JOBS_INDEX_FILE_NAME), content: emitJobsIndexModule(emittedJobs, { jobsLabel }) }] : []),
271
+ { path: nodePath.join(outDirectory, CLIENT_INDEX_FILE_NAME), content: emitClientIndexModule(emitted, { controllersLabel, hasJobs: emittedJobs.length > 0 }) },
272
+ { path: resolve(config.scriptsOutFile), content: emitScriptsModule(emitted, { wireModule: config.wireModule, controllersLabel, jobs: emittedJobs, jobsLabel, jobRuns }) },
153
273
  ];
154
274
  const leftoverFiles = findLeftoverFiles(fileSystem, outDirectory, files.map((file) => file.path));
155
- return { files, leftoverFiles, controllers, problems };
275
+ return { files, leftoverFiles, controllers, jobs, problems };
156
276
  }
157
277
  export function runClientGeneration(options) {
158
278
  const plan = planClientGeneration(options);
@@ -1,14 +1,17 @@
1
1
  /** The generator as a library: what the `netsuite-api` command runs, for a build tool or a test to call directly. */
2
2
  export { DEFAULT_CONFIG_FILE_NAME, ClientGeneratorConfigError, defaultClientGeneratorConfig, loadClientGeneratorConfig } from './config.js';
3
- export type { ClientGeneratorConfig, ResolvedClientGeneratorConfig } from './config.js';
3
+ export { JOB_RUN_FIELD_SUFFIXES, netsuiteValueTypeNames, resolveJobRunFieldIds } from './config.js';
4
+ export type { ClientGeneratorConfig, JobRunFieldName, JobRunsSettings, NetsuiteValueTypeName, ResolvedClientGeneratorConfig } from './config.js';
4
5
  export { GENERATED_CLIENT_NAME, GENERATED_ENDPOINTS_TYPE_NAME, RAW_RESPONSE_TYPE_NAME, isControllerFileName, readControllerContract, readLeadingJsDoc } from './controllerReader.js';
5
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, jobDefinitionFileName, readJobContract, readJobFolderName } from './jobReader.js';
8
+ export type { DeclaredJob, JobContract, JobParameterContract, JobReadResult, JobStageName } from './jobReader.js';
6
9
  export { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
7
10
  export type { InlinableTypeDeclaration, InlinableTypesFile, SelectedInlinedTypes } from './typesFileReader.js';
8
- export { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitClientIndexModule, emitControllerModule, emitScriptsModule } from './emit.js';
9
- export type { EmitClientIndexModuleOptions, EmitControllerModuleOptions, EmitScriptsModuleOptions, EmittedController, InlinedTypeSection } from './emit.js';
11
+ export { CLIENT_INDEX_FILE_NAME, JOBS_INDEX_FILE_NAME, controllerModuleFileName, emitClientIndexModule, emitControllerModule, emitJobModule, emitJobsIndexModule, emitScriptsModule, jobModuleFileName } from './emit.js';
12
+ export type { EmitClientIndexModuleOptions, EmitControllerModuleOptions, EmitScriptsModuleOptions, EmittedController, EmittedJob, EmittedJobRuns, InlinedTypeSection } from './emit.js';
10
13
  export { checkClientGeneration, planClientGeneration, runClientGeneration } from './generate.js';
11
- export type { ClientGenerationCheck, ClientGenerationPlan, ClientGenerationResult, GenerateClientOptions, PlannedController, PlannedFile } from './generate.js';
14
+ export type { ClientGenerationCheck, ClientGenerationPlan, ClientGenerationResult, GenerateClientOptions, PlannedController, PlannedFile, PlannedJob } from './generate.js';
12
15
  export { createInMemoryFileSystemAdapter, createNodeFileSystemAdapter, toPosixPath } from './file-system.js';
13
16
  export type { FileSystemAdapter, InMemoryFileSystemAdapter } from './file-system.js';
14
17
  export { CLI_USAGE, EXIT_PROBLEMS, EXIT_SUCCESS, EXIT_USAGE, runCli } from './cli/main.js';
@@ -1,8 +1,10 @@
1
1
  /** The generator as a library: what the `netsuite-api` command runs, for a build tool or a test to call directly. */
2
2
  export { DEFAULT_CONFIG_FILE_NAME, ClientGeneratorConfigError, defaultClientGeneratorConfig, loadClientGeneratorConfig } from './config.js';
3
+ export { JOB_RUN_FIELD_SUFFIXES, netsuiteValueTypeNames, resolveJobRunFieldIds } from './config.js';
3
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, jobDefinitionFileName, readJobContract, readJobFolderName } from './jobReader.js';
4
6
  export { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
5
- export { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitClientIndexModule, emitControllerModule, emitScriptsModule } from './emit.js';
7
+ export { CLIENT_INDEX_FILE_NAME, JOBS_INDEX_FILE_NAME, controllerModuleFileName, emitClientIndexModule, emitControllerModule, emitJobModule, emitJobsIndexModule, emitScriptsModule, jobModuleFileName } from './emit.js';
6
8
  export { checkClientGeneration, planClientGeneration, runClientGeneration } from './generate.js';
7
9
  export { createInMemoryFileSystemAdapter, createNodeFileSystemAdapter, toPosixPath } from './file-system.js';
8
10
  export { CLI_USAGE, EXIT_PROBLEMS, EXIT_SUCCESS, EXIT_USAGE, runCli } from './cli/main.js';
@@ -0,0 +1,83 @@
1
+ import type { CarriedTypeImport, ControllerProblem, InlinedTypeImport, ReadControllerOptions, TypeDeclaration } from './controllerReader.js';
2
+ /**
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.
12
+ */
13
+ /** The stage names a job may declare, in the order NetSuite calls them. */
14
+ export declare const JOB_STAGE_NAMES: readonly ["getInputData", "map", "reduce", "summarize"];
15
+ export type JobStageName = (typeof JOB_STAGE_NAMES)[number];
16
+ /** The script type a job's leading JSDoc must declare. */
17
+ export declare const JOB_SCRIPT_TYPE_HEADER = "MapReduceScript";
18
+ /** The type the generated job module gives the run's input, and the one it gives the result. */
19
+ export declare const GENERATED_JOB_RESULT_TYPE_NAME = "Result";
20
+ declare const netsuiteValueTypes: readonly ["text", "integer", "decimal", "checkbox", "date", "select"];
21
+ export interface JobParameterContract {
22
+ /** The name the stages read it by. */
23
+ name: string;
24
+ id: string;
25
+ type: (typeof netsuiteValueTypes)[number];
26
+ }
27
+ /** The script a job declares, as read off its defineJob call. */
28
+ export interface DeclaredJob {
29
+ scriptId: string;
30
+ deployments: string[];
31
+ runParameter: string;
32
+ parameters: JobParameterContract[];
33
+ }
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.
37
+ */
38
+ export interface JobStageFile {
39
+ filePath: string;
40
+ declarations: TypeDeclaration[];
41
+ inlinedTypeImports: InlinedTypeImport[];
42
+ carriedTypeImports: CarriedTypeImport[];
43
+ }
44
+ /** What a job's reader needs beyond a controller's: the files its stages are imported from. */
45
+ export interface ReadJobOptions extends ReadControllerOptions {
46
+ /**
47
+ * Reads a file a stage is imported from, the specifier resolved against the job's own folder, or
48
+ * undefined when there is no such file. Without it, only inline stages can be read.
49
+ */
50
+ readStageFile?(specifier: string): {
51
+ filePath: string;
52
+ source: string;
53
+ } | undefined;
54
+ }
55
+ export interface JobContract {
56
+ /** The job's name: `closeStaleOrders` for closeStaleOrders.ts, matching `name` in its declaration. */
57
+ name: string;
58
+ filePath: string;
59
+ script: DeclaredJob;
60
+ carriedTypeImports: CarriedTypeImport[];
61
+ inlinedTypeImports: InlinedTypeImport[];
62
+ typeDeclarations: TypeDeclaration[];
63
+ /** The type of getInputData's first parameter, or undefined when a run takes no input. */
64
+ inputType?: string;
65
+ /** What summarize returns, or undefined when the job has no summarize stage of its own. */
66
+ resultType?: string;
67
+ /** The stages the job declares. */
68
+ stages: JobStageName[];
69
+ /** The stages the file exports as NetSuite entry points. */
70
+ exportedStages: string[];
71
+ /** The files the stages were imported from, in the order they were read. */
72
+ stageFiles: JobStageFile[];
73
+ }
74
+ export interface JobReadResult {
75
+ contract?: JobContract;
76
+ problems: ControllerProblem[];
77
+ }
78
+ /** The name a job folder declares: `approveOrders` for api/src/jobs/approveOrders, or undefined when it is not a name. */
79
+ export declare function readJobFolderName(folderName: string): string | undefined;
80
+ /** 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. */
81
+ export declare function jobDefinitionFileName(jobName: string): string;
82
+ export declare function readJobContract(filePath: string, source: string, options: ReadJobOptions): JobReadResult;
83
+ export {};