@amerilux/netsuite-api 0.4.0 → 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 +81 -5
- package/dist/client/index.d.ts +1 -1
- package/dist/index.d.ts +137 -0
- 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/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/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 +33 -0
- package/dist-tooling/config.js +84 -2
- package/dist-tooling/controllerReader.d.ts +22 -0
- package/dist-tooling/controllerReader.js +5 -5
- package/dist-tooling/emit.d.ts +42 -0
- package/dist-tooling/emit.js +126 -3
- package/dist-tooling/generate.d.ts +10 -1
- package/dist-tooling/generate.js +115 -25
- 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/package.json +1 -1
package/dist-tooling/config.js
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import * as nodePath from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* What each run field's id ends in, under the configured prefix: `custrecord_<prefix>_jr` plus
|
|
4
|
+
* `_status` is the status field. Short on purpose, because NetSuite caps a field id at 40 characters.
|
|
5
|
+
*/
|
|
6
|
+
export const JOB_RUN_FIELD_SUFFIXES = {
|
|
7
|
+
job: '_job',
|
|
8
|
+
status: '_status',
|
|
9
|
+
stage: '_stage',
|
|
10
|
+
stagePercentComplete: '_percent',
|
|
11
|
+
input: '_input',
|
|
12
|
+
result: '_result',
|
|
13
|
+
errors: '_errors',
|
|
14
|
+
taskId: '_task',
|
|
15
|
+
deployment: '_deploy',
|
|
16
|
+
startedBy: '_by',
|
|
17
|
+
startedAt: '_start',
|
|
18
|
+
finishedAt: '_end',
|
|
19
|
+
};
|
|
20
|
+
export const netsuiteValueTypeNames = ['text', 'integer', 'decimal', 'checkbox', 'date', 'select'];
|
|
2
21
|
export const DEFAULT_CONFIG_FILE_NAME = 'netsuite-api.config.json';
|
|
3
22
|
export const defaultClientGeneratorConfig = {
|
|
4
23
|
controllers: 'api/src/controllers',
|
|
24
|
+
jobs: 'api/src/jobs',
|
|
5
25
|
outDir: 'client/src/api',
|
|
6
26
|
scriptsOutFile: 'api/src/scripts.gen.ts',
|
|
7
27
|
clientModule: '@amerilux/netsuite-api/client',
|
|
@@ -42,11 +62,71 @@ export class ClientGeneratorConfigError extends Error {
|
|
|
42
62
|
this.name = 'ClientGeneratorConfigError';
|
|
43
63
|
}
|
|
44
64
|
}
|
|
45
|
-
const stringSettings = ['controllers', 'outDir', 'scriptsOutFile', 'clientModule', 'wireModule'];
|
|
65
|
+
const stringSettings = ['controllers', 'jobs', 'outDir', 'scriptsOutFile', 'clientModule', 'wireModule'];
|
|
46
66
|
const mapSettings = ['typeImports', 'inlineTypes'];
|
|
47
67
|
function isStringMap(value) {
|
|
48
68
|
return !!value && typeof value === 'object' && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === 'string' && entry !== '');
|
|
49
69
|
}
|
|
70
|
+
function isPlainObject(value) {
|
|
71
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
72
|
+
}
|
|
73
|
+
/** The run record block: the record's own ids, and the fields the application added to it. */
|
|
74
|
+
function validateJobRunsSettings(raw, problems) {
|
|
75
|
+
if (!isPlainObject(raw)) {
|
|
76
|
+
problems.push("'jobRuns' must be an object with 'recordType' and 'fieldPrefix'.");
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const recordType = raw.recordType;
|
|
80
|
+
const fieldPrefix = raw.fieldPrefix;
|
|
81
|
+
if (typeof recordType !== 'string' || recordType.trim() === '')
|
|
82
|
+
problems.push("'jobRuns.recordType' must be the run record's id, such as 'customrecord_app_job_run'.");
|
|
83
|
+
if (typeof fieldPrefix !== 'string' || fieldPrefix.trim() === '')
|
|
84
|
+
problems.push("'jobRuns.fieldPrefix' must be what the run record's field ids start with, such as 'custrecord_app_jr'.");
|
|
85
|
+
const settings = { recordType: String(recordType ?? ''), fieldPrefix: String(fieldPrefix ?? '') };
|
|
86
|
+
if (raw.fields !== undefined) {
|
|
87
|
+
if (!isStringMap(raw.fields))
|
|
88
|
+
problems.push("'jobRuns.fields' must be an object of strings: the fields whose ids differ from the prefix plus the usual suffix.");
|
|
89
|
+
else {
|
|
90
|
+
for (const name of Object.keys(raw.fields)) {
|
|
91
|
+
if (!(name in JOB_RUN_FIELD_SUFFIXES))
|
|
92
|
+
problems.push(`'jobRuns.fields.${name}' is not a run field; the fields are ${Object.keys(JOB_RUN_FIELD_SUFFIXES).join(', ')}.`);
|
|
93
|
+
}
|
|
94
|
+
settings.fields = raw.fields;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (raw.extraFields !== undefined) {
|
|
98
|
+
if (!isPlainObject(raw.extraFields))
|
|
99
|
+
problems.push("'jobRuns.extraFields' must be an object of { id, type } by field name.");
|
|
100
|
+
else {
|
|
101
|
+
const extraFields = {};
|
|
102
|
+
for (const [name, field] of Object.entries(raw.extraFields)) {
|
|
103
|
+
if (!isPlainObject(field) || typeof field.id !== 'string' || field.id === '') {
|
|
104
|
+
problems.push(`'jobRuns.extraFields.${name}' needs 'id', the field's id in NetSuite.`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (typeof field.type !== 'string' || !netsuiteValueTypeNames.includes(field.type)) {
|
|
108
|
+
problems.push(`'jobRuns.extraFields.${name}.type' must be one of ${netsuiteValueTypeNames.map((value) => `'${value}'`).join(', ')}.`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
extraFields[name] = { id: field.id, type: field.type };
|
|
112
|
+
}
|
|
113
|
+
settings.extraFields = extraFields;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const key of Object.keys(raw)) {
|
|
117
|
+
if (!['recordType', 'fieldPrefix', 'fields', 'extraFields'].includes(key))
|
|
118
|
+
problems.push(`'jobRuns.${key}' is not a setting.`);
|
|
119
|
+
}
|
|
120
|
+
return settings;
|
|
121
|
+
}
|
|
122
|
+
/** Every run field's id: the prefix plus its suffix, or the override the config gives it. */
|
|
123
|
+
export function resolveJobRunFieldIds(settings) {
|
|
124
|
+
const ids = {};
|
|
125
|
+
for (const [name, suffix] of Object.entries(JOB_RUN_FIELD_SUFFIXES)) {
|
|
126
|
+
ids[name] = settings.fields?.[name] ?? `${settings.fieldPrefix}${suffix}`;
|
|
127
|
+
}
|
|
128
|
+
return ids;
|
|
129
|
+
}
|
|
50
130
|
function validateClientGeneratorConfig(raw, configPath) {
|
|
51
131
|
const problems = [];
|
|
52
132
|
const config = { ...defaultClientGeneratorConfig, typeImports: { ...defaultClientGeneratorConfig.typeImports }, inlineTypes: { ...defaultClientGeneratorConfig.inlineTypes } };
|
|
@@ -77,7 +157,9 @@ function validateClientGeneratorConfig(raw, configPath) {
|
|
|
77
157
|
else if (stars === 0 && filePattern.includes('*'))
|
|
78
158
|
problems.push(`'inlineTypes' file '${filePattern}' has a '*' but its key '${key}' has none to take the name from.`);
|
|
79
159
|
}
|
|
80
|
-
|
|
160
|
+
if (raw.jobRuns !== undefined)
|
|
161
|
+
config.jobRuns = validateJobRunsSettings(raw.jobRuns, problems);
|
|
162
|
+
const known = new Set([...stringSettings, ...mapSettings, 'jobRuns']);
|
|
81
163
|
for (const key of Object.keys(raw)) {
|
|
82
164
|
if (!known.has(key))
|
|
83
165
|
problems.push(`'${key}' is not a setting.`);
|
|
@@ -84,6 +84,28 @@ export interface ControllerReadResult {
|
|
|
84
84
|
problems: ControllerProblem[];
|
|
85
85
|
}
|
|
86
86
|
export declare function isControllerFileName(fileName: string): boolean;
|
|
87
|
+
export declare function hasExportModifier(node: ts.Node): boolean;
|
|
87
88
|
/** The JSDoc block directly above a node (no blank line between them), or undefined. */
|
|
88
89
|
export declare function readLeadingJsDoc(node: ts.Node, sourceFile: ts.SourceFile): string | undefined;
|
|
90
|
+
/** The script type NetSuite reads from the leading JSDoc, or undefined. */
|
|
91
|
+
export declare function readScriptTypeHeader(source: string): string | undefined;
|
|
92
|
+
export type ReadTypeImport = {
|
|
93
|
+
kind: 'carried';
|
|
94
|
+
typeImport: CarriedTypeImport;
|
|
95
|
+
} | {
|
|
96
|
+
kind: 'inlined';
|
|
97
|
+
typeImport: InlinedTypeImport;
|
|
98
|
+
} | {
|
|
99
|
+
kind: 'controller';
|
|
100
|
+
typeImport: ControllerTypeImport;
|
|
101
|
+
};
|
|
102
|
+
export declare function readTypeImport(statement: ts.ImportDeclaration, filePath: string, options: ReadControllerOptions): {
|
|
103
|
+
read?: ReadTypeImport;
|
|
104
|
+
problems: ControllerProblem[];
|
|
105
|
+
};
|
|
106
|
+
export declare function findCall(statement: ts.VariableStatement, calleeNames: string[]): {
|
|
107
|
+
declarationName: string;
|
|
108
|
+
call: ts.CallExpression;
|
|
109
|
+
} | undefined;
|
|
110
|
+
export declare function readStringProperty(literal: ts.ObjectLiteralExpression, propertyName: string): string | undefined;
|
|
89
111
|
export declare function readControllerContract(filePath: string, source: string, options: ReadControllerOptions): ControllerReadResult;
|
|
@@ -17,7 +17,7 @@ const entryPointByFunction = {
|
|
|
17
17
|
export function isControllerFileName(fileName) {
|
|
18
18
|
return controllerFileNamePattern.test(fileName);
|
|
19
19
|
}
|
|
20
|
-
function hasExportModifier(node) {
|
|
20
|
+
export function hasExportModifier(node) {
|
|
21
21
|
return ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
|
22
22
|
}
|
|
23
23
|
/** The JSDoc block directly above a node (no blank line between them), or undefined. */
|
|
@@ -32,11 +32,11 @@ export function readLeadingJsDoc(node, sourceFile) {
|
|
|
32
32
|
return sourceFile.text.slice(jsDocRange.pos, jsDocRange.end);
|
|
33
33
|
}
|
|
34
34
|
/** The script type NetSuite reads from the leading JSDoc, or undefined. */
|
|
35
|
-
function readScriptTypeHeader(source) {
|
|
35
|
+
export function readScriptTypeHeader(source) {
|
|
36
36
|
const header = source.match(/^\s*(\/\*[\s\S]*?\*\/)/)?.[1] ?? '';
|
|
37
37
|
return header.match(/@NScriptType\s+(\w+)/)?.[1];
|
|
38
38
|
}
|
|
39
|
-
function readTypeImport(statement, filePath, options) {
|
|
39
|
+
export function readTypeImport(statement, filePath, options) {
|
|
40
40
|
const problems = [];
|
|
41
41
|
const clause = statement.importClause;
|
|
42
42
|
if (!clause || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
@@ -119,7 +119,7 @@ function readEndpoint(property, filePath, sourceFile) {
|
|
|
119
119
|
problems,
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
|
-
function findCall(statement, calleeNames) {
|
|
122
|
+
export function findCall(statement, calleeNames) {
|
|
123
123
|
for (const declaration of statement.declarationList.declarations) {
|
|
124
124
|
if (!ts.isIdentifier(declaration.name))
|
|
125
125
|
continue;
|
|
@@ -130,7 +130,7 @@ function findCall(statement, calleeNames) {
|
|
|
130
130
|
}
|
|
131
131
|
return undefined;
|
|
132
132
|
}
|
|
133
|
-
function readStringProperty(literal, propertyName) {
|
|
133
|
+
export function readStringProperty(literal, propertyName) {
|
|
134
134
|
for (const property of literal.properties) {
|
|
135
135
|
if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === propertyName && ts.isStringLiteral(property.initializer))
|
|
136
136
|
return property.initializer.text;
|
package/dist-tooling/emit.d.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import type { ControllerContract, TypeDeclaration } from './controllerReader.js';
|
|
2
|
+
import type { JobRunFieldName } from './config.js';
|
|
3
|
+
import type { JobContract } from './jobReader.js';
|
|
2
4
|
/**
|
|
3
5
|
* Writes the generated modules. Each controller gets its own client module, `<name>.gen.ts`: the
|
|
4
6
|
* entity and service types it names (copied in, so the module stands on its own), its wire shapes,
|
|
5
7
|
* its endpoint type and, when the browser calls it, its client. The index module re-exports each one as a
|
|
6
8
|
* namespace, so a hook writes `user.api.roles()` and names a shape as `user.RolesResponse`. The
|
|
7
9
|
* scripts module is the server-side map a repository passes to createSuiteletClient.
|
|
10
|
+
*
|
|
11
|
+
* A job gets a module of the same kind, `<name>Job.gen.ts`, holding the shape a finished run carries
|
|
12
|
+
* (`Result`) and nothing callable: a page starts a job through a controller, not directly, and a run's
|
|
13
|
+
* input is the service's own type, named where the run is started rather than in the browser.
|
|
14
|
+
* They are reached under `jobs` (`jobs.closeStaleOrders.Result`). Server-side, the scripts module
|
|
15
|
+
* carries the jobs as well, and the run record's ids next to them.
|
|
8
16
|
*/
|
|
9
17
|
/** The declarations copied from one inlined type file. */
|
|
10
18
|
export interface InlinedTypeSection {
|
|
@@ -24,17 +32,51 @@ export interface EmitControllerModuleOptions {
|
|
|
24
32
|
export interface EmitClientIndexModuleOptions {
|
|
25
33
|
/** How the header names the sources: `api/src/controllers`. */
|
|
26
34
|
controllersLabel: string;
|
|
35
|
+
/** True when the project has jobs, so the index re-exports them under `jobs`. */
|
|
36
|
+
hasJobs?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** The run record's ids as the scripts module writes them out. */
|
|
39
|
+
export interface EmittedJobRuns {
|
|
40
|
+
recordType: string;
|
|
41
|
+
fields: Record<JobRunFieldName, string>;
|
|
42
|
+
extraFields: Record<string, {
|
|
43
|
+
id: string;
|
|
44
|
+
type: string;
|
|
45
|
+
}>;
|
|
27
46
|
}
|
|
28
47
|
export interface EmitScriptsModuleOptions {
|
|
29
48
|
wireModule: string;
|
|
30
49
|
controllersLabel: string;
|
|
50
|
+
/** The jobs to carry alongside the controllers, and where they came from. */
|
|
51
|
+
jobs?: EmittedJob[];
|
|
52
|
+
jobsLabel?: string;
|
|
53
|
+
/** The run record, written out when the project has one. */
|
|
54
|
+
jobRuns?: EmittedJobRuns;
|
|
31
55
|
}
|
|
32
56
|
/** The index module of the client, next to the controller modules: what a hook imports. */
|
|
33
57
|
export declare const CLIENT_INDEX_FILE_NAME = "index.gen.ts";
|
|
58
|
+
/** The module re-exporting every job module, reached as `jobs` from the client index. */
|
|
59
|
+
export declare const JOBS_INDEX_FILE_NAME = "jobs.gen.ts";
|
|
34
60
|
/** The client module of a controller: `user.gen.ts` for the user controller. */
|
|
35
61
|
export declare function controllerModuleFileName(controllerName: string): string;
|
|
62
|
+
/** The client module of a job: `closeStaleOrdersJob.gen.ts` for the closeStaleOrders job. */
|
|
63
|
+
export declare function jobModuleFileName(jobName: string): string;
|
|
64
|
+
/** A job as the generator writes it out: its contract, and the types copied in with it. */
|
|
65
|
+
export interface EmittedJob {
|
|
66
|
+
contract: JobContract;
|
|
67
|
+
/** Where the file's header points a reader: the job's path relative to the project. */
|
|
68
|
+
sourceLabel: string;
|
|
69
|
+
inlinedTypes: InlinedTypeSection[];
|
|
70
|
+
}
|
|
36
71
|
export declare function emitControllerModule({ contract, sourceLabel, inlinedTypes }: EmittedController, options: EmitControllerModuleOptions): string;
|
|
37
72
|
export declare function sortControllers(controllers: EmittedController[]): EmittedController[];
|
|
38
73
|
/** The index module: every controller module re-exported under the controller's name. */
|
|
39
74
|
export declare function emitClientIndexModule(controllers: EmittedController[], options: EmitClientIndexModuleOptions): string;
|
|
75
|
+
export declare function sortJobs(jobs: EmittedJob[]): EmittedJob[];
|
|
76
|
+
/** A job's module: the shapes a run is started with and ends in, and the types they are built from. */
|
|
77
|
+
export declare function emitJobModule({ contract, sourceLabel, inlinedTypes }: EmittedJob): string;
|
|
78
|
+
/** The jobs index: every job module under the job's name, reached as `jobs.<name>` from the client index. */
|
|
79
|
+
export declare function emitJobsIndexModule(jobs: EmittedJob[], options: {
|
|
80
|
+
jobsLabel: string;
|
|
81
|
+
}): string;
|
|
40
82
|
export declare function emitScriptsModule(controllers: EmittedController[], options: EmitScriptsModuleOptions): string;
|
package/dist-tooling/emit.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { GENERATED_CLIENT_NAME, GENERATED_ENDPOINTS_TYPE_NAME } from './controllerReader.js';
|
|
2
|
-
import {
|
|
2
|
+
import { GENERATED_JOB_RESULT_TYPE_NAME } from './jobReader.js';
|
|
3
|
+
import { readReferencedNames, writeDatesAsStrings } from './wireTypes.js';
|
|
3
4
|
/** The index module of the client, next to the controller modules: what a hook imports. */
|
|
4
5
|
export const CLIENT_INDEX_FILE_NAME = 'index.gen.ts';
|
|
6
|
+
/** The module re-exporting every job module, reached as `jobs` from the client index. */
|
|
7
|
+
export const JOBS_INDEX_FILE_NAME = 'jobs.gen.ts';
|
|
5
8
|
/** The client module of a controller: `user.gen.ts` for the user controller. */
|
|
6
9
|
export function controllerModuleFileName(controllerName) {
|
|
7
10
|
return `${controllerName}.gen.ts`;
|
|
8
11
|
}
|
|
12
|
+
/** The client module of a job: `closeStaleOrdersJob.gen.ts` for the closeStaleOrders job. */
|
|
13
|
+
export function jobModuleFileName(jobName) {
|
|
14
|
+
return `${jobName}Job.gen.ts`;
|
|
15
|
+
}
|
|
9
16
|
const INDENT = ' ';
|
|
17
|
+
/** What a summarize stage returns when the run has no result to carry; the run record holds null either way. */
|
|
18
|
+
const RESULTLESS_TYPE_NAMES = new Set(['void', 'undefined', 'null', 'never']);
|
|
10
19
|
const EDIT_NOTICE = 'Do not edit: change the controller and run `npm run generate`.';
|
|
11
20
|
const ESLINT_DISABLE = '/* eslint-disable */';
|
|
12
21
|
function indentJsDoc(jsDoc) {
|
|
@@ -86,21 +95,135 @@ export function emitClientIndexModule(controllers, options) {
|
|
|
86
95
|
: `The ${contract.name} controller: its request and response types only; server code calls it, the browser does not.`;
|
|
87
96
|
lines.push(`/** ${summary} */`, `export * as ${contract.name} from './${controllerModuleFileName(contract.name).replace(/\.ts$/, '')}';`);
|
|
88
97
|
}
|
|
98
|
+
if (options.hasJobs) {
|
|
99
|
+
lines.push('/** Every job of this application: the shapes a run of each is started with and ends in. */', `export * as jobs from './${JOBS_INDEX_FILE_NAME.replace(/\.ts$/, '')}';`);
|
|
100
|
+
}
|
|
89
101
|
return `${lines.join('\n')}\n`;
|
|
90
102
|
}
|
|
103
|
+
export function sortJobs(jobs) {
|
|
104
|
+
return [...jobs].sort((left, right) => left.contract.name.localeCompare(right.contract.name));
|
|
105
|
+
}
|
|
106
|
+
/** A job's module: the shapes a run is started with and ends in, and the types they are built from. */
|
|
107
|
+
export function emitJobModule({ contract, sourceLabel, inlinedTypes }) {
|
|
108
|
+
// A job that answers nothing has no summarize, or one that returns nothing: either way the run's result is null,
|
|
109
|
+
// which is what the record holds and what the page reads back.
|
|
110
|
+
const resultType = contract.resultType === undefined || RESULTLESS_TYPE_NAMES.has(contract.resultType.trim()) ? 'null' : writeDatesAsStrings(contract.resultType, 'type');
|
|
111
|
+
// Only what the result names, because that is all this module declares: the shape a run is started with is
|
|
112
|
+
// the service's, named where the run is started, and a stage's own item types never leave the server.
|
|
113
|
+
const carried = findTypesReachedBy(resultType, contract.typeDeclarations, inlinedTypes);
|
|
114
|
+
const declarations = [];
|
|
115
|
+
for (const section of inlinedTypes) {
|
|
116
|
+
const kept = section.declarations.filter((declaration) => carried.has(declaration.name));
|
|
117
|
+
if (kept.length === 0)
|
|
118
|
+
continue;
|
|
119
|
+
declarations.push(`// Types from ${section.sourceLabel}, copied so this module stands on its own.`);
|
|
120
|
+
for (const declaration of kept)
|
|
121
|
+
declarations.push(writeDatesAsStrings(declaration.text, 'declaration'));
|
|
122
|
+
}
|
|
123
|
+
for (const declaration of contract.typeDeclarations) {
|
|
124
|
+
if (carried.has(declaration.name))
|
|
125
|
+
declarations.push(writeDatesAsStrings(declaration.text, 'declaration'));
|
|
126
|
+
}
|
|
127
|
+
// A job's stages name package types the result does not (JobSummary on summarize), and those are
|
|
128
|
+
// server-side: only an import a shape actually reaches is carried over.
|
|
129
|
+
const emittedText = [...declarations, resultType].join('\n');
|
|
130
|
+
const usedTypeImports = contract.carriedTypeImports
|
|
131
|
+
.map((typeImport) => ({ ...typeImport, names: typeImport.names.filter((imported) => new RegExp(`\\b${imported.alias ?? imported.name}\\b`).test(emittedText)) }))
|
|
132
|
+
.filter((typeImport) => typeImport.names.length > 0);
|
|
133
|
+
const sections = [
|
|
134
|
+
[
|
|
135
|
+
`// Generated by netsuite-api generate from ${sourceLabel}. Do not edit: change the job and run \`npm run generate\`.`,
|
|
136
|
+
'// A job is started through a controller, so this module carries its types and nothing callable.',
|
|
137
|
+
ESLINT_DISABLE,
|
|
138
|
+
].join('\n'),
|
|
139
|
+
emitTypeImports(usedTypeImports).join('\n'),
|
|
140
|
+
...declarations,
|
|
141
|
+
];
|
|
142
|
+
sections.push(`/** What a finished run of the ${contract.name} job carries as its result. */\nexport type ${GENERATED_JOB_RESULT_TYPE_NAME} = ${resultType};`);
|
|
143
|
+
return `${sections.filter((section) => section !== '').join('\n\n')}\n`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The declared shapes a type names, and the shapes those name in turn: what a module has to carry for the
|
|
147
|
+
* type to stand on its own. A name with no declaration here belongs to the package or to the language, and
|
|
148
|
+
* the type import that brought it is what carries it.
|
|
149
|
+
*/
|
|
150
|
+
function findTypesReachedBy(type, ownDeclarations, inlinedTypes) {
|
|
151
|
+
const declarationsByName = new Map();
|
|
152
|
+
for (const section of inlinedTypes)
|
|
153
|
+
for (const declaration of section.declarations)
|
|
154
|
+
declarationsByName.set(declaration.name, declaration.text);
|
|
155
|
+
for (const declaration of ownDeclarations)
|
|
156
|
+
declarationsByName.set(declaration.name, declaration.text);
|
|
157
|
+
const carried = new Set();
|
|
158
|
+
const pending = readReferencedNames(type, 'type');
|
|
159
|
+
while (pending.length > 0) {
|
|
160
|
+
const name = pending.shift();
|
|
161
|
+
if (carried.has(name))
|
|
162
|
+
continue;
|
|
163
|
+
const text = declarationsByName.get(name);
|
|
164
|
+
if (text === undefined)
|
|
165
|
+
continue;
|
|
166
|
+
carried.add(name);
|
|
167
|
+
pending.push(...readReferencedNames(text, 'declaration'));
|
|
168
|
+
}
|
|
169
|
+
return carried;
|
|
170
|
+
}
|
|
171
|
+
/** The jobs index: every job module under the job's name, reached as `jobs.<name>` from the client index. */
|
|
172
|
+
export function emitJobsIndexModule(jobs, options) {
|
|
173
|
+
const lines = [`// Generated by netsuite-api generate from ${options.jobsLabel}. Do not edit: change the jobs and run \`npm run generate\`.`, ESLINT_DISABLE, ''];
|
|
174
|
+
for (const { contract } of sortJobs(jobs)) {
|
|
175
|
+
lines.push(`/** The ${contract.name} job: the shape a finished run of it carries. */`, `export * as ${contract.name} from './${jobModuleFileName(contract.name).replace(/\.ts$/, '')}';`);
|
|
176
|
+
}
|
|
177
|
+
return `${lines.join('\n')}\n`;
|
|
178
|
+
}
|
|
179
|
+
/** The job reference as the scripts module writes it: what startJob submits. */
|
|
180
|
+
function emitJobRef(contract) {
|
|
181
|
+
const { scriptId, deployments, runParameter, parameters } = contract.script;
|
|
182
|
+
const deploymentList = deployments.map((deployment) => `'${deployment}'`).join(', ');
|
|
183
|
+
const parameterEntries = parameters.map((parameter) => `${parameter.name}: '${parameter.id}'`).join(', ');
|
|
184
|
+
const parameterProperty = parameters.length > 0 ? `, parameters: { ${parameterEntries} }` : '';
|
|
185
|
+
return `{ kind: 'mapreduce', name: '${contract.name}', scriptId: '${scriptId}', deployments: [${deploymentList}], runParameter: '${runParameter}'${parameterProperty} }`;
|
|
186
|
+
}
|
|
187
|
+
function emitJobRunsConfig(jobRuns) {
|
|
188
|
+
const fieldEntries = Object.entries(jobRuns.fields).map(([name, id]) => `${INDENT}${INDENT}${name}: '${id}',`);
|
|
189
|
+
const extraEntries = Object.entries(jobRuns.extraFields).map(([name, field]) => `${INDENT}${INDENT}${name}: { id: '${field.id}', type: '${field.type}' },`);
|
|
190
|
+
return [
|
|
191
|
+
'/** The run record this application deployed: what a job and the run store read and write a run through. */',
|
|
192
|
+
'export const jobRuns = {',
|
|
193
|
+
`${INDENT}recordType: '${jobRuns.recordType}',`,
|
|
194
|
+
`${INDENT}fields: {`,
|
|
195
|
+
...fieldEntries,
|
|
196
|
+
`${INDENT}},`,
|
|
197
|
+
...(extraEntries.length > 0 ? [`${INDENT}extraFields: {`, ...extraEntries, `${INDENT}},`] : [`${INDENT}extraFields: {},`]),
|
|
198
|
+
'} as const satisfies JobRunsConfig;',
|
|
199
|
+
];
|
|
200
|
+
}
|
|
91
201
|
export function emitScriptsModule(controllers, options) {
|
|
92
202
|
const ordered = sortControllers(controllers);
|
|
93
203
|
const entries = ordered.map(({ contract }) => `${INDENT}${contract.name}: ${emitScriptRef(contract.script)},`);
|
|
204
|
+
const jobs = sortJobs(options.jobs ?? []);
|
|
205
|
+
const importedTypes = ['ScriptRef', ...(jobs.length > 0 ? ['JobRef'] : []), ...(options.jobRuns ? ['JobRunsConfig'] : [])].sort();
|
|
206
|
+
const sourceLabels = [options.controllersLabel, ...(jobs.length > 0 && options.jobsLabel ? [options.jobsLabel] : [])].join(' and ');
|
|
94
207
|
return [
|
|
95
|
-
`// Generated by netsuite-api generate from ${
|
|
208
|
+
`// Generated by netsuite-api generate from ${sourceLabels}. ${EDIT_NOTICE}`,
|
|
96
209
|
ESLINT_DISABLE,
|
|
97
210
|
'',
|
|
98
|
-
`import type {
|
|
211
|
+
`import type { ${importedTypes.join(', ')} } from '${options.wireModule}';`,
|
|
99
212
|
'',
|
|
100
213
|
'/** Every script the controllers declare, by controller name: what a repository passes to createSuiteletClient. */',
|
|
101
214
|
'export const scripts = {',
|
|
102
215
|
...entries,
|
|
103
216
|
'} as const satisfies Record<string, ScriptRef>;',
|
|
217
|
+
...(jobs.length > 0
|
|
218
|
+
? [
|
|
219
|
+
'',
|
|
220
|
+
'/** Every job, by name: what a repository passes to the run store to start one. */',
|
|
221
|
+
'export const jobs = {',
|
|
222
|
+
...jobs.map(({ contract }) => `${INDENT}${contract.name}: ${emitJobRef(contract)},`),
|
|
223
|
+
'} as const satisfies Record<string, JobRef>;',
|
|
224
|
+
]
|
|
225
|
+
: []),
|
|
226
|
+
...(options.jobRuns ? ['', ...emitJobRunsConfig(options.jobRuns)] : []),
|
|
104
227
|
'',
|
|
105
228
|
].join('\n');
|
|
106
229
|
}
|
|
@@ -12,17 +12,26 @@ export interface PlannedController {
|
|
|
12
12
|
browser: boolean;
|
|
13
13
|
endpointCount: number;
|
|
14
14
|
}
|
|
15
|
+
export interface PlannedJob {
|
|
16
|
+
name: string;
|
|
17
|
+
filePath: string;
|
|
18
|
+
/** The stages the job declares, in the order NetSuite calls them. */
|
|
19
|
+
stages: string[];
|
|
20
|
+
/** How many deployments a run can be started on: how many runs of the job can overlap. */
|
|
21
|
+
deploymentCount: number;
|
|
22
|
+
}
|
|
15
23
|
export interface PlannedFile {
|
|
16
24
|
/** Absolute path. */
|
|
17
25
|
path: string;
|
|
18
26
|
content: string;
|
|
19
27
|
}
|
|
20
28
|
export interface ClientGenerationPlan {
|
|
21
|
-
/** The modules to write: one per controller, the client index, the scripts map; empty when there are problems. */
|
|
29
|
+
/** The modules to write: one per controller and per job, the client index, the jobs index, the scripts map; empty when there are problems. */
|
|
22
30
|
files: PlannedFile[];
|
|
23
31
|
/** Generated files in the client's output directory the plan does not write: a removed controller's module, or the copy of the entity types an earlier version made. Absolute paths. */
|
|
24
32
|
leftoverFiles: string[];
|
|
25
33
|
controllers: PlannedController[];
|
|
34
|
+
jobs: PlannedJob[];
|
|
26
35
|
problems: ControllerProblem[];
|
|
27
36
|
}
|
|
28
37
|
export interface ClientGenerationResult extends ClientGenerationPlan {
|
package/dist-tooling/generate.js
CHANGED
|
@@ -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 { isJobFileName, readJobContract } 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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
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,48 @@ export function planClientGeneration({ config, fileSystem }) {
|
|
|
132
169
|
problems.push(...findDatesInRequestShapes(contract, inlinedTypes, controllerLabel));
|
|
133
170
|
emitted.push({ contract, sourceLabel: controllerLabel, inlinedTypes });
|
|
134
171
|
}
|
|
135
|
-
|
|
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));
|
|
136
214
|
const controllers = emitted.map(({ contract }) => ({
|
|
137
215
|
name: contract.name,
|
|
138
216
|
filePath: contract.filePath,
|
|
@@ -140,19 +218,31 @@ export function planClientGeneration({ config, fileSystem }) {
|
|
|
140
218
|
browser: contract.script.browser,
|
|
141
219
|
endpointCount: contract.endpoints.length,
|
|
142
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
|
+
}));
|
|
143
227
|
if (problems.length > 0)
|
|
144
|
-
return { files: [], leftoverFiles: [], controllers, problems };
|
|
228
|
+
return { files: [], leftoverFiles: [], controllers, jobs, problems };
|
|
145
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;
|
|
146
234
|
const files = [
|
|
147
235
|
...sortControllers(emitted).map((controller) => ({
|
|
148
236
|
path: nodePath.join(outDirectory, controllerModuleFileName(controller.contract.name)),
|
|
149
237
|
content: emitControllerModule(controller, { clientModule: config.clientModule }),
|
|
150
238
|
})),
|
|
151
|
-
{ path: nodePath.join(outDirectory,
|
|
152
|
-
{ 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 }) },
|
|
153
243
|
];
|
|
154
244
|
const leftoverFiles = findLeftoverFiles(fileSystem, outDirectory, files.map((file) => file.path));
|
|
155
|
-
return { files, leftoverFiles, controllers, problems };
|
|
245
|
+
return { files, leftoverFiles, controllers, jobs, problems };
|
|
156
246
|
}
|
|
157
247
|
export function runClientGeneration(options) {
|
|
158
248
|
const plan = planClientGeneration(options);
|