@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.
- package/README.md +89 -11
- package/dist/client/apiClient.js +2 -2
- package/dist/client/index.d.ts +1 -1
- package/dist/index.d.ts +143 -1
- package/dist/server/apiError.d.ts +2 -0
- package/dist/server/apiError.js +4 -0
- package/dist/server/defineJob.d.ts +95 -0
- package/dist/server/defineJob.js +150 -0
- package/dist/server/endpoint.js +1 -1
- package/dist/server/index.d.ts +8 -3
- package/dist/server/index.js +5 -2
- package/dist/server/jobRuns.d.ts +65 -0
- package/dist/server/jobRuns.js +292 -0
- package/dist/server/suiteletClient.js +2 -1
- package/dist/testing/N/task.d.ts +14 -0
- package/dist/testing/N/task.js +4 -1
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/jobs.d.ts +50 -0
- package/dist/testing/jobs.js +90 -0
- package/dist-tooling/cli/main.js +6 -3
- package/dist-tooling/config.d.ts +45 -4
- package/dist-tooling/config.js +117 -3
- package/dist-tooling/controllerReader.d.ts +24 -2
- package/dist-tooling/controllerReader.js +7 -6
- package/dist-tooling/emit.d.ts +44 -2
- package/dist-tooling/emit.js +132 -7
- package/dist-tooling/generate.d.ts +10 -1
- package/dist-tooling/generate.js +166 -28
- package/dist-tooling/index.d.ts +7 -4
- package/dist-tooling/index.js +3 -1
- package/dist-tooling/jobReader.d.ts +53 -0
- package/dist-tooling/jobReader.js +260 -0
- package/dist-tooling/typesFileReader.d.ts +28 -9
- package/dist-tooling/typesFileReader.js +73 -41
- package/dist-tooling/wireTypes.d.ts +16 -0
- package/dist-tooling/wireTypes.js +50 -0
- package/package.json +1 -1
package/dist-tooling/generate.js
CHANGED
|
@@ -1,23 +1,97 @@
|
|
|
1
1
|
import * as nodePath from 'node:path';
|
|
2
|
+
import { resolveInlineTypesFile, resolveJobRunFieldIds } from './config.js';
|
|
2
3
|
import { isControllerFileName, readControllerContract } from './controllerReader.js';
|
|
3
|
-
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 { isJobFileName, readJobContract } from './jobReader.js';
|
|
4
6
|
import { toPosixPath } from './file-system.js';
|
|
5
7
|
import { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
|
|
8
|
+
import { readReferencedNames } from './wireTypes.js';
|
|
6
9
|
const generatedFileNamePattern = /\.gen\.ts$/;
|
|
7
10
|
function relativeLabel(rootDirectory, filePath) {
|
|
8
11
|
return toPosixPath(nodePath.relative(rootDirectory, filePath));
|
|
9
12
|
}
|
|
10
|
-
function findDuplicateScriptIds(controllers) {
|
|
13
|
+
function findDuplicateScriptIds(controllers, jobs) {
|
|
11
14
|
const problems = [];
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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');
|
|
32
|
+
}
|
|
33
|
+
return problems;
|
|
34
|
+
}
|
|
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) {
|
|
37
|
+
const declarations = new Map();
|
|
38
|
+
for (const declaration of own)
|
|
39
|
+
declarations.set(declaration.name, declaration.text);
|
|
40
|
+
for (const section of inlinedTypes) {
|
|
41
|
+
for (const declaration of section.declarations)
|
|
42
|
+
if (!declarations.has(declaration.name))
|
|
43
|
+
declarations.set(declaration.name, declaration.text);
|
|
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);
|
|
78
|
+
const problems = [];
|
|
79
|
+
for (const endpoint of contract.endpoints) {
|
|
80
|
+
if (endpoint.requestType === undefined)
|
|
81
|
+
continue;
|
|
82
|
+
const visited = new Set();
|
|
83
|
+
const pending = readReferencedNames(endpoint.requestType, 'type').map((name) => ({ name, via: endpoint.requestType }));
|
|
84
|
+
while (pending.length > 0) {
|
|
85
|
+
const { name, via } = pending.shift();
|
|
86
|
+
if (name === 'Date') {
|
|
87
|
+
problems.push({ filePath: controllerLabel, message: `endpoint '${endpoint.name}' takes a Date in its request (through ${via}); JSON carries dates as ISO 8601 strings, so take a string and parse it in the handler.` });
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
const text = declarations.get(name);
|
|
91
|
+
if (text === undefined || visited.has(name))
|
|
92
|
+
continue;
|
|
93
|
+
visited.add(name);
|
|
94
|
+
pending.push(...readReferencedNames(text, 'declaration').map((reference) => ({ name: reference, via: name })));
|
|
21
95
|
}
|
|
22
96
|
}
|
|
23
97
|
return problems;
|
|
@@ -41,15 +115,23 @@ export function planClientGeneration({ config, fileSystem }) {
|
|
|
41
115
|
}
|
|
42
116
|
const controllerNames = new Set(controllerFiles.map((filePath) => nodePath.basename(filePath).replace(/Controller\.ts$/, '')));
|
|
43
117
|
const inlinableFiles = new Map();
|
|
118
|
+
/** The inlinable file a specifier names, read once; a file that does not exist is reported once and answered as 'missing'. */
|
|
44
119
|
const readInlinable = (specifier) => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
120
|
+
const known = inlinableFiles.get(specifier);
|
|
121
|
+
if (known !== undefined)
|
|
122
|
+
return known;
|
|
123
|
+
const relativePath = resolveInlineTypesFile(config.inlineTypes, specifier);
|
|
124
|
+
if (relativePath === undefined)
|
|
125
|
+
return undefined;
|
|
126
|
+
const filePath = resolve(relativePath);
|
|
48
127
|
let file;
|
|
49
128
|
if (fileSystem.fileExists(filePath))
|
|
50
129
|
file = readInlinableTypesFile(label(filePath), fileSystem.readTextFile(filePath));
|
|
51
|
-
else
|
|
52
|
-
|
|
130
|
+
else {
|
|
131
|
+
file = 'missing';
|
|
132
|
+
const hint = config.inlineTypes[specifier] !== undefined ? 'run the model generator first.' : `'${specifier}' names it.`;
|
|
133
|
+
problems.push({ filePath: label(filePath), message: `the file to copy types from does not exist; ${hint}` });
|
|
134
|
+
}
|
|
53
135
|
inlinableFiles.set(specifier, file);
|
|
54
136
|
return file;
|
|
55
137
|
};
|
|
@@ -72,19 +154,63 @@ export function planClientGeneration({ config, fileSystem }) {
|
|
|
72
154
|
const inlinedTypes = [];
|
|
73
155
|
for (const typeImport of contract.inlinedTypeImports) {
|
|
74
156
|
const file = readInlinable(typeImport.specifier);
|
|
75
|
-
if (!file)
|
|
157
|
+
if (!file || file === 'missing')
|
|
76
158
|
continue;
|
|
77
|
-
const selected = selectInlinedTypes(file, typeImport.names, controllerLabel);
|
|
159
|
+
const selected = selectInlinedTypes(file, typeImport.names, controllerLabel, readInlinable);
|
|
78
160
|
problems.push(...selected.problems);
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
161
|
+
for (const selectedSection of selected.sections) {
|
|
162
|
+
const section = inlinedTypes.find((existing) => existing.sourceLabel === selectedSection.filePath);
|
|
163
|
+
if (section)
|
|
164
|
+
section.declarations.push(...selectedSection.declarations.filter((declaration) => !section.declarations.some((existing) => existing.name === declaration.name)));
|
|
165
|
+
else
|
|
166
|
+
inlinedTypes.push({ sourceLabel: selectedSection.filePath, declarations: selectedSection.declarations });
|
|
167
|
+
}
|
|
84
168
|
}
|
|
169
|
+
problems.push(...findDatesInRequestShapes(contract, inlinedTypes, controllerLabel));
|
|
85
170
|
emitted.push({ contract, sourceLabel: controllerLabel, inlinedTypes });
|
|
86
171
|
}
|
|
87
|
-
|
|
172
|
+
// Jobs: the same reading, with a run instead of a request and a record instead of a reply.
|
|
173
|
+
const jobsDirectory = resolve(config.jobs);
|
|
174
|
+
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.' });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const result = readJobContract(jobLabel, fileSystem.readTextFile(filePath), { typeImports: config.typeImports, inlineTypes: config.inlineTypes });
|
|
182
|
+
problems.push(...result.problems);
|
|
183
|
+
const contract = result.contract;
|
|
184
|
+
if (!contract)
|
|
185
|
+
continue;
|
|
186
|
+
if (controllerNames.has(contract.name) && jobModuleFileName(contract.name) === controllerModuleFileName(contract.name)) {
|
|
187
|
+
problems.push({ filePath: jobLabel, message: `a controller is named '${contract.name}' too; their generated modules would be the same file.` });
|
|
188
|
+
}
|
|
189
|
+
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
|
+
}
|
|
203
|
+
}
|
|
204
|
+
problems.push(...findDatesInJobInput(contract, inlinedTypes, jobLabel));
|
|
205
|
+
emittedJobs.push({ contract, sourceLabel: jobLabel, inlinedTypes });
|
|
206
|
+
}
|
|
207
|
+
if (emittedJobs.length > 0 && config.jobRuns === undefined) {
|
|
208
|
+
problems.push({
|
|
209
|
+
filePath: label(jobsDirectory),
|
|
210
|
+
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).',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
problems.push(...findDuplicateScriptIds(emitted, emittedJobs));
|
|
88
214
|
const controllers = emitted.map(({ contract }) => ({
|
|
89
215
|
name: contract.name,
|
|
90
216
|
filePath: contract.filePath,
|
|
@@ -92,19 +218,31 @@ export function planClientGeneration({ config, fileSystem }) {
|
|
|
92
218
|
browser: contract.script.browser,
|
|
93
219
|
endpointCount: contract.endpoints.length,
|
|
94
220
|
}));
|
|
221
|
+
const jobs = emittedJobs.map(({ contract }) => ({
|
|
222
|
+
name: contract.name,
|
|
223
|
+
filePath: contract.filePath,
|
|
224
|
+
stages: contract.stages,
|
|
225
|
+
deploymentCount: contract.script.deployments.length,
|
|
226
|
+
}));
|
|
95
227
|
if (problems.length > 0)
|
|
96
|
-
return { files: [], leftoverFiles: [], controllers, problems };
|
|
228
|
+
return { files: [], leftoverFiles: [], controllers, jobs, problems };
|
|
97
229
|
const controllersLabel = label(controllersDirectory);
|
|
230
|
+
const jobsLabel = label(jobsDirectory);
|
|
231
|
+
const jobRuns = config.jobRuns
|
|
232
|
+
? { recordType: config.jobRuns.recordType, fields: resolveJobRunFieldIds(config.jobRuns), extraFields: config.jobRuns.extraFields ?? {} }
|
|
233
|
+
: undefined;
|
|
98
234
|
const files = [
|
|
99
235
|
...sortControllers(emitted).map((controller) => ({
|
|
100
236
|
path: nodePath.join(outDirectory, controllerModuleFileName(controller.contract.name)),
|
|
101
237
|
content: emitControllerModule(controller, { clientModule: config.clientModule }),
|
|
102
238
|
})),
|
|
103
|
-
{ path: nodePath.join(outDirectory,
|
|
104
|
-
{ path:
|
|
239
|
+
...sortJobs(emittedJobs).map((job) => ({ path: nodePath.join(outDirectory, jobModuleFileName(job.contract.name)), content: emitJobModule(job) })),
|
|
240
|
+
...(emittedJobs.length > 0 ? [{ path: nodePath.join(outDirectory, JOBS_INDEX_FILE_NAME), content: emitJobsIndexModule(emittedJobs, { jobsLabel }) }] : []),
|
|
241
|
+
{ path: nodePath.join(outDirectory, CLIENT_INDEX_FILE_NAME), content: emitClientIndexModule(emitted, { controllersLabel, hasJobs: emittedJobs.length > 0 }) },
|
|
242
|
+
{ path: resolve(config.scriptsOutFile), content: emitScriptsModule(emitted, { wireModule: config.wireModule, controllersLabel, jobs: emittedJobs, jobsLabel, jobRuns }) },
|
|
105
243
|
];
|
|
106
244
|
const leftoverFiles = findLeftoverFiles(fileSystem, outDirectory, files.map((file) => file.path));
|
|
107
|
-
return { files, leftoverFiles, controllers, problems };
|
|
245
|
+
return { files, leftoverFiles, controllers, jobs, problems };
|
|
108
246
|
}
|
|
109
247
|
export function runClientGeneration(options) {
|
|
110
248
|
const plan = planClientGeneration(options);
|
package/dist-tooling/index.d.ts
CHANGED
|
@@ -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
|
|
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, isJobFileName, readJobContract } 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';
|
package/dist-tooling/index.js
CHANGED
|
@@ -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, isJobFileName, readJobContract } 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,53 @@
|
|
|
1
|
+
import type { CarriedTypeImport, ControllerProblem, InlinedTypeImport, ReadControllerOptions, TypeDeclaration } from './controllerReader.js';
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
/** The stage names a job may declare, in the order NetSuite calls them. */
|
|
10
|
+
export declare const JOB_STAGE_NAMES: readonly ["getInputData", "map", "reduce", "summarize"];
|
|
11
|
+
export type JobStageName = (typeof JOB_STAGE_NAMES)[number];
|
|
12
|
+
/** The script type a job's leading JSDoc must declare. */
|
|
13
|
+
export declare const JOB_SCRIPT_TYPE_HEADER = "MapReduceScript";
|
|
14
|
+
/** The type the generated job module gives the run's input, and the one it gives the result. */
|
|
15
|
+
export declare const GENERATED_JOB_RESULT_TYPE_NAME = "Result";
|
|
16
|
+
declare const netsuiteValueTypes: readonly ["text", "integer", "decimal", "checkbox", "date", "select"];
|
|
17
|
+
export interface JobParameterContract {
|
|
18
|
+
/** The name the stages read it by. */
|
|
19
|
+
name: string;
|
|
20
|
+
id: string;
|
|
21
|
+
type: (typeof netsuiteValueTypes)[number];
|
|
22
|
+
}
|
|
23
|
+
/** The script a job declares, as read off its defineJob call. */
|
|
24
|
+
export interface DeclaredJob {
|
|
25
|
+
scriptId: string;
|
|
26
|
+
deployments: string[];
|
|
27
|
+
runParameter: string;
|
|
28
|
+
parameters: JobParameterContract[];
|
|
29
|
+
}
|
|
30
|
+
export interface JobContract {
|
|
31
|
+
/** The job's name: `closeStaleOrders` for closeStaleOrders.ts, matching `name` in its declaration. */
|
|
32
|
+
name: string;
|
|
33
|
+
filePath: string;
|
|
34
|
+
script: DeclaredJob;
|
|
35
|
+
carriedTypeImports: CarriedTypeImport[];
|
|
36
|
+
inlinedTypeImports: InlinedTypeImport[];
|
|
37
|
+
typeDeclarations: TypeDeclaration[];
|
|
38
|
+
/** The type of getInputData's first parameter, or undefined when a run takes no input. */
|
|
39
|
+
inputType?: string;
|
|
40
|
+
/** What summarize returns, or undefined when the job has no summarize stage of its own. */
|
|
41
|
+
resultType?: string;
|
|
42
|
+
/** The stages the job declares. */
|
|
43
|
+
stages: JobStageName[];
|
|
44
|
+
/** The stages the file exports as NetSuite entry points. */
|
|
45
|
+
exportedStages: string[];
|
|
46
|
+
}
|
|
47
|
+
export interface JobReadResult {
|
|
48
|
+
contract?: JobContract;
|
|
49
|
+
problems: ControllerProblem[];
|
|
50
|
+
}
|
|
51
|
+
export declare function isJobFileName(fileName: string): boolean;
|
|
52
|
+
export declare function readJobContract(filePath: string, source: string, options: ReadControllerOptions): JobReadResult;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import * as nodePath from 'node:path';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
import { hasExportModifier, readLeadingJsDoc, readScriptTypeHeader, readStringProperty, readTypeImport } from './controllerReader.js';
|
|
4
|
+
import { toPosixPath } from './file-system.js';
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
/** The stage names a job may declare, in the order NetSuite calls them. */
|
|
13
|
+
export const JOB_STAGE_NAMES = ['getInputData', 'map', 'reduce', 'summarize'];
|
|
14
|
+
/** The script type a job's leading JSDoc must declare. */
|
|
15
|
+
export const JOB_SCRIPT_TYPE_HEADER = 'MapReduceScript';
|
|
16
|
+
/** The type the generated job module gives the run's input, and the one it gives the result. */
|
|
17
|
+
export const GENERATED_JOB_RESULT_TYPE_NAME = 'Result';
|
|
18
|
+
const jobFileNamePattern = /^([a-z][A-Za-z0-9]*)\.ts$/;
|
|
19
|
+
const netsuiteValueTypes = ['text', 'integer', 'decimal', 'checkbox', 'date', 'select'];
|
|
20
|
+
export function isJobFileName(fileName) {
|
|
21
|
+
return jobFileNamePattern.test(fileName) && !fileName.endsWith('.d.ts');
|
|
22
|
+
}
|
|
23
|
+
/** `export const { getInputData, map, summarize } = defineJob(...)`: the call, and the names it exports. */
|
|
24
|
+
function findDefineJobCall(statement) {
|
|
25
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
26
|
+
const initializer = declaration.initializer;
|
|
27
|
+
if (!initializer || !ts.isCallExpression(initializer) || !ts.isIdentifier(initializer.expression) || initializer.expression.text !== 'defineJob')
|
|
28
|
+
continue;
|
|
29
|
+
if (!ts.isObjectBindingPattern(declaration.name))
|
|
30
|
+
return { call: initializer, exportedStages: [], destructured: false };
|
|
31
|
+
const exportedStages = declaration.name.elements
|
|
32
|
+
.map((element) => (ts.isIdentifier(element.name) ? element.name.text : undefined))
|
|
33
|
+
.filter((exported) => exported !== undefined);
|
|
34
|
+
return { call: initializer, exportedStages, destructured: true };
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
function readStringArrayProperty(literal, propertyName) {
|
|
39
|
+
for (const property of literal.properties) {
|
|
40
|
+
if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name) || property.name.text !== propertyName)
|
|
41
|
+
continue;
|
|
42
|
+
if (!ts.isArrayLiteralExpression(property.initializer))
|
|
43
|
+
return undefined;
|
|
44
|
+
const values = property.initializer.elements.map((element) => (ts.isStringLiteral(element) ? element.text : undefined));
|
|
45
|
+
return values.every((value) => value !== undefined) ? values : undefined;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
function hasProperty(literal, propertyName) {
|
|
50
|
+
return literal.properties.some((property) => ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === propertyName);
|
|
51
|
+
}
|
|
52
|
+
function readObjectProperty(literal, propertyName) {
|
|
53
|
+
for (const property of literal.properties) {
|
|
54
|
+
if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === propertyName && ts.isObjectLiteralExpression(property.initializer)) {
|
|
55
|
+
return property.initializer;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
function readParameters(declaration, filePath) {
|
|
61
|
+
const problems = [];
|
|
62
|
+
const parameters = [];
|
|
63
|
+
const literal = readObjectProperty(declaration, 'parameters');
|
|
64
|
+
if (!literal) {
|
|
65
|
+
if (hasProperty(declaration, 'parameters'))
|
|
66
|
+
problems.push({ filePath, message: "'parameters' must be an object literal of { id, type } written inline; the generator reads the ids from it." });
|
|
67
|
+
return { parameters, problems };
|
|
68
|
+
}
|
|
69
|
+
for (const property of literal.properties) {
|
|
70
|
+
if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name)) {
|
|
71
|
+
problems.push({ filePath, message: 'every script parameter is named by a plain identifier and declared as { id, type }.' });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const name = property.name.text;
|
|
75
|
+
if (!ts.isObjectLiteralExpression(property.initializer)) {
|
|
76
|
+
problems.push({ filePath, message: `parameter '${name}' must be written as { id: 'custscript_...', type: '...' }.` });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const id = readStringProperty(property.initializer, 'id');
|
|
80
|
+
const type = readStringProperty(property.initializer, 'type');
|
|
81
|
+
if (id === undefined)
|
|
82
|
+
problems.push({ filePath, message: `parameter '${name}' needs 'id' as a string literal: the script parameter's id in NetSuite.` });
|
|
83
|
+
if (type === undefined || !netsuiteValueTypes.includes(type)) {
|
|
84
|
+
problems.push({ filePath, message: `parameter '${name}' needs 'type' as one of ${netsuiteValueTypes.map((value) => `'${value}'`).join(', ')}.` });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (id !== undefined)
|
|
88
|
+
parameters.push({ name, id, type: type });
|
|
89
|
+
}
|
|
90
|
+
return { parameters, problems };
|
|
91
|
+
}
|
|
92
|
+
function readJobDeclaration(declaration, jobName, filePath) {
|
|
93
|
+
const problems = [];
|
|
94
|
+
const name = readStringProperty(declaration, 'name');
|
|
95
|
+
const scriptId = readStringProperty(declaration, 'scriptId');
|
|
96
|
+
const runParameter = readStringProperty(declaration, 'runParameter');
|
|
97
|
+
const deployments = readStringArrayProperty(declaration, 'deployments');
|
|
98
|
+
if (name === undefined)
|
|
99
|
+
problems.push({ filePath, message: "the job declaration needs 'name' as a string literal; the generator reads it from the source." });
|
|
100
|
+
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.` });
|
|
102
|
+
if (scriptId === undefined)
|
|
103
|
+
problems.push({ filePath, message: "the job declaration needs 'scriptId' as a string literal." });
|
|
104
|
+
if (runParameter === undefined) {
|
|
105
|
+
problems.push({ filePath, message: "the job declaration needs 'runParameter' as a string literal: the script parameter the run id is passed in." });
|
|
106
|
+
}
|
|
107
|
+
if (deployments === undefined) {
|
|
108
|
+
problems.push({ filePath, message: "the job declaration needs 'deployments' as an array of string literals: every deployment a run may be started on." });
|
|
109
|
+
}
|
|
110
|
+
else if (deployments.length === 0) {
|
|
111
|
+
problems.push({ filePath, message: "'deployments' is empty; a job needs at least one deployment to run on." });
|
|
112
|
+
}
|
|
113
|
+
if (!hasProperty(declaration, 'runs')) {
|
|
114
|
+
problems.push({ filePath, message: "the job declaration needs 'runs: jobRuns', the run record from the generated scripts map; the stages read the run through it." });
|
|
115
|
+
}
|
|
116
|
+
const readParametersResult = readParameters(declaration, filePath);
|
|
117
|
+
problems.push(...readParametersResult.problems);
|
|
118
|
+
if (problems.length > 0 || scriptId === undefined || runParameter === undefined || deployments === undefined)
|
|
119
|
+
return { problems };
|
|
120
|
+
return { script: { scriptId, deployments, runParameter, parameters: readParametersResult.parameters }, problems };
|
|
121
|
+
}
|
|
122
|
+
function readStages(literal, filePath, sourceFile) {
|
|
123
|
+
const problems = [];
|
|
124
|
+
const stages = [];
|
|
125
|
+
let inputType;
|
|
126
|
+
let resultType;
|
|
127
|
+
for (const property of literal.properties) {
|
|
128
|
+
const stageName = property.name && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) ? property.name.text : undefined;
|
|
129
|
+
if (stageName === undefined || !JOB_STAGE_NAMES.includes(stageName)) {
|
|
130
|
+
problems.push({ filePath, message: `'${stageName ?? 'a stage'}' is not a stage; a job declares ${JOB_STAGE_NAMES.join(', ')}.` });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
let handler;
|
|
134
|
+
if (ts.isMethodDeclaration(property))
|
|
135
|
+
handler = property;
|
|
136
|
+
else if (ts.isPropertyAssignment(property) && (ts.isArrowFunction(property.initializer) || ts.isFunctionExpression(property.initializer)))
|
|
137
|
+
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;
|
|
141
|
+
}
|
|
142
|
+
stages.push(stageName);
|
|
143
|
+
if (stageName === 'getInputData') {
|
|
144
|
+
const parameter = handler.parameters[0];
|
|
145
|
+
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." });
|
|
147
|
+
}
|
|
148
|
+
inputType = parameter?.type?.getText(sourceFile);
|
|
149
|
+
}
|
|
150
|
+
if (stageName === 'summarize') {
|
|
151
|
+
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);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!stages.includes('getInputData'))
|
|
157
|
+
problems.push({ filePath, message: 'a job declares getInputData; it is what NetSuite asks for the run\'s work.' });
|
|
158
|
+
if (!stages.includes('map') && !stages.includes('reduce')) {
|
|
159
|
+
problems.push({ filePath, message: 'a job declares a map stage, a reduce stage, or both; NetSuite has nothing to run otherwise.' });
|
|
160
|
+
}
|
|
161
|
+
return { stages, inputType, resultType, problems };
|
|
162
|
+
}
|
|
163
|
+
export function readJobContract(filePath, source, options) {
|
|
164
|
+
const problems = [];
|
|
165
|
+
const fileName = nodePath.basename(toPosixPath(filePath));
|
|
166
|
+
const nameMatch = jobFileNamePattern.exec(fileName);
|
|
167
|
+
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.' }] };
|
|
169
|
+
const name = nameMatch[1];
|
|
170
|
+
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
171
|
+
const header = readScriptTypeHeader(source);
|
|
172
|
+
if (header !== JOB_SCRIPT_TYPE_HEADER) {
|
|
173
|
+
problems.push({ filePath, message: `the leading JSDoc says '@NScriptType ${header ?? '(none)'}'; a job is a ${JOB_SCRIPT_TYPE_HEADER}.` });
|
|
174
|
+
}
|
|
175
|
+
const carriedTypeImports = [];
|
|
176
|
+
const inlinedTypeImports = [];
|
|
177
|
+
const typeDeclarations = [];
|
|
178
|
+
let contract;
|
|
179
|
+
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
|
+
if (!ts.isVariableStatement(statement))
|
|
206
|
+
continue;
|
|
207
|
+
const found = findDefineJobCall(statement);
|
|
208
|
+
if (!found)
|
|
209
|
+
continue;
|
|
210
|
+
if (contract) {
|
|
211
|
+
problems.push({ filePath, message: 'a job file has one defineJob call; a second one is a second script.' });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (!hasExportModifier(statement) || !found.destructured) {
|
|
215
|
+
problems.push({ filePath, message: "the stages are exported from the call: `export const { getInputData, map, summarize } = defineJob({ ... }, { ... })`; NetSuite looks for those exports." });
|
|
216
|
+
}
|
|
217
|
+
const [declarationArgument, stagesArgument] = found.call.arguments;
|
|
218
|
+
if (!declarationArgument || !ts.isObjectLiteralExpression(declarationArgument)) {
|
|
219
|
+
problems.push({ filePath, message: 'defineJob takes the job declaration as an object literal written inline.' });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (!stagesArgument || !ts.isObjectLiteralExpression(stagesArgument)) {
|
|
223
|
+
problems.push({ filePath, message: 'defineJob takes the stages as an object literal written inline, each stage a function with its types annotated.' });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const declared = readJobDeclaration(declarationArgument, name, filePath);
|
|
227
|
+
problems.push(...declared.problems);
|
|
228
|
+
const stages = readStages(stagesArgument, filePath, sourceFile);
|
|
229
|
+
problems.push(...stages.problems);
|
|
230
|
+
for (const exported of found.exportedStages) {
|
|
231
|
+
if (!JOB_STAGE_NAMES.includes(exported)) {
|
|
232
|
+
problems.push({ filePath, message: `'${exported}' is not a stage, so NetSuite has no entry point by that name; export ${JOB_STAGE_NAMES.join(', ')}.` });
|
|
233
|
+
}
|
|
234
|
+
else if (exported !== 'summarize' && !stages.stages.includes(exported)) {
|
|
235
|
+
problems.push({ filePath, message: `'${exported}' is exported but not declared; a stage exported without a declaration throws when NetSuite calls it.` });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
for (const stage of stages.stages) {
|
|
239
|
+
if (!found.exportedStages.includes(stage)) {
|
|
240
|
+
problems.push({ filePath, message: `stage '${stage}' is declared but not exported; NetSuite only runs the stages the file exports.` });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// The run is closed in summarize, so a job that leaves it out would leave every run of it looking unfinished.
|
|
244
|
+
if (!found.exportedStages.includes('summarize')) {
|
|
245
|
+
problems.push({ filePath, message: 'every job exports summarize, declared or not: it is where the run is closed and its result written.' });
|
|
246
|
+
}
|
|
247
|
+
if (!declared.script)
|
|
248
|
+
continue;
|
|
249
|
+
contract = { name, filePath, script: declared.script, inputType: stages.inputType, resultType: stages.resultType, stages: stages.stages, exportedStages: found.exportedStages };
|
|
250
|
+
}
|
|
251
|
+
if (!contract) {
|
|
252
|
+
if (problems.length === 0) {
|
|
253
|
+
problems.push({ filePath, message: 'must declare its script: `export const { getInputData, map, summarize } = defineJob({ name, scriptId, deployments, runParameter, runs }, { ... })`.' });
|
|
254
|
+
}
|
|
255
|
+
return { problems };
|
|
256
|
+
}
|
|
257
|
+
if (problems.length > 0)
|
|
258
|
+
return { problems };
|
|
259
|
+
return { contract: { ...contract, carriedTypeImports, inlinedTypeImports, typeDeclarations }, problems };
|
|
260
|
+
}
|