@amerilux/netsuite-api 0.1.0 → 0.2.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.
@@ -3,8 +3,12 @@ import ts from 'typescript';
3
3
  import { toPosixPath } from './file-system.js';
4
4
  /** The return type a handler writes, exactly, to answer with a document instead of the envelope. */
5
5
  export const RAW_RESPONSE_TYPE_NAME = 'RawResponse';
6
+ /** The type every generated controller module declares for its endpoint signatures: `user.Endpoints`. */
7
+ export const GENERATED_ENDPOINTS_TYPE_NAME = 'Endpoints';
8
+ /** The client every generated browser-facing controller module exports: `user.api`. */
9
+ export const GENERATED_CLIENT_NAME = 'api';
6
10
  const controllerFileNamePattern = /^([a-z][A-Za-z0-9]*)Controller\.ts$/;
7
- const siblingControllerSpecifierPattern = /^\.\/[a-z][A-Za-z0-9]*Controller(?:\.js|\.ts)?$/;
11
+ const siblingControllerSpecifierPattern = /^\.\/([a-z][A-Za-z0-9]*)Controller(?:\.js|\.ts)?$/;
8
12
  const entryPointByFunction = {
9
13
  defineRestlet: { kind: 'restlet', exportName: 'post', header: 'Restlet' },
10
14
  defineSuitelet: { kind: 'suitelet', exportName: 'onRequest', header: 'Suitelet' },
@@ -12,9 +16,6 @@ const entryPointByFunction = {
12
16
  export function isControllerFileName(fileName) {
13
17
  return controllerFileNamePattern.test(fileName);
14
18
  }
15
- export function toPascalCase(name) {
16
- return name.charAt(0).toUpperCase() + name.slice(1);
17
- }
18
19
  function hasExportModifier(node) {
19
20
  return ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
20
21
  }
@@ -51,7 +52,7 @@ function readTypeImport(statement, filePath, options) {
51
52
  if (bindings && ts.isNamedImports(bindings)) {
52
53
  for (const element of bindings.elements) {
53
54
  if (clause.isTypeOnly || element.isTypeOnly)
54
- names.push(element.propertyName ? `${element.propertyName.text} as ${element.name.text}` : element.name.text);
55
+ names.push(element.propertyName ? { name: element.propertyName.text, alias: element.name.text } : { name: element.name.text });
55
56
  }
56
57
  }
57
58
  if (clause.name && clause.isTypeOnly) {
@@ -59,13 +60,17 @@ function readTypeImport(statement, filePath, options) {
59
60
  }
60
61
  if (names.length === 0)
61
62
  return { problems };
62
- if (siblingControllerSpecifierPattern.test(moduleSpecifier))
63
- return { problems };
63
+ const sibling = siblingControllerSpecifierPattern.exec(moduleSpecifier);
64
+ if (sibling)
65
+ return { read: { kind: 'controller', typeImport: { controllerName: sibling[1], names } }, problems };
66
+ if (options.inlineTypes[moduleSpecifier] !== undefined)
67
+ return { read: { kind: 'inlined', typeImport: { specifier: moduleSpecifier, names } }, problems };
64
68
  const clientSpecifier = options.typeImports[moduleSpecifier];
65
69
  if (clientSpecifier !== undefined)
66
- return { typeImport: { moduleSpecifier: clientSpecifier, names }, problems };
67
- const allowed = Object.keys(options.typeImports).map((specifier) => `'${specifier}'`).join(', ');
68
- problems.push({ filePath, message: `type ${names.join(', ')} is imported from '${moduleSpecifier}'; a controller's wire shapes may only take types from ${allowed || 'the configured typeImports'} or from another controller.` });
70
+ return { read: { kind: 'carried', typeImport: { moduleSpecifier: clientSpecifier, names } }, problems };
71
+ const allowed = [...Object.keys(options.inlineTypes), ...Object.keys(options.typeImports)].map((specifier) => `'${specifier}'`).join(', ');
72
+ const written = names.map((imported) => (imported.alias ? `${imported.name} as ${imported.alias}` : imported.name)).join(', ');
73
+ problems.push({ filePath, message: `type ${written} is imported from '${moduleSpecifier}'; a controller's wire shapes may only take types from ${allowed || 'the configured inlineTypes and typeImports'} or from another controller.` });
69
74
  return { problems };
70
75
  }
71
76
  function isTypeQueryAlias(statement) {
@@ -190,7 +195,9 @@ export function readControllerContract(filePath, source, options) {
190
195
  const name = nameMatch[1];
191
196
  const endpointsConstantName = `${name}Endpoints`;
192
197
  const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
193
- const typeImports = [];
198
+ const carriedTypeImports = [];
199
+ const inlinedTypeImports = [];
200
+ const controllerTypeImports = [];
194
201
  const typeDeclarations = [];
195
202
  const endpoints = [];
196
203
  let endpointsFound = false;
@@ -200,8 +207,12 @@ export function readControllerContract(filePath, source, options) {
200
207
  if (ts.isImportDeclaration(statement)) {
201
208
  const result = readTypeImport(statement, filePath, options);
202
209
  problems.push(...result.problems);
203
- if (result.typeImport)
204
- typeImports.push(result.typeImport);
210
+ if (result.read?.kind === 'carried')
211
+ carriedTypeImports.push(result.read.typeImport);
212
+ else if (result.read?.kind === 'inlined')
213
+ inlinedTypeImports.push(result.read.typeImport);
214
+ else if (result.read?.kind === 'controller')
215
+ controllerTypeImports.push(result.read.typeImport);
205
216
  }
206
217
  else if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement)) {
207
218
  if (isTypeQueryAlias(statement)) {
@@ -215,6 +226,10 @@ export function readControllerContract(filePath, source, options) {
215
226
  problems.push({ filePath, message: `'${statement.name.text}' is not exported; every type in a controller is a wire shape, so export it (or move it below the controller).` });
216
227
  continue;
217
228
  }
229
+ if (statement.name.text === GENERATED_ENDPOINTS_TYPE_NAME) {
230
+ problems.push({ filePath, message: `type '${GENERATED_ENDPOINTS_TYPE_NAME}' is the name the generated module gives the endpoint signatures; call the wire shape something else.` });
231
+ continue;
232
+ }
218
233
  const jsDoc = readLeadingJsDoc(statement, sourceFile);
219
234
  typeDeclarations.push({ name: statement.name.text, text: `${jsDoc ? `${jsDoc}\n` : ''}${statement.getText(sourceFile)}` });
220
235
  }
@@ -261,7 +276,7 @@ export function readControllerContract(filePath, source, options) {
261
276
  if (problems.length > 0 || !script)
262
277
  return { problems };
263
278
  return {
264
- contract: { name, filePath, endpointsTypeName: `${toPascalCase(name)}Endpoints`, clientName: `${name}Api`, script, typeImports, typeDeclarations, endpoints },
279
+ contract: { name, filePath, script, carriedTypeImports, inlinedTypeImports, controllerTypeImports, typeDeclarations, endpoints },
265
280
  problems,
266
281
  };
267
282
  }
@@ -1,17 +1,27 @@
1
- import type { ControllerContract } from './controllerReader.js';
1
+ import type { ControllerContract, TypeDeclaration } from './controllerReader.js';
2
2
  /**
3
- * Writes the generated modules. The client module holds the app declarations, every controller's
4
- * wire shapes and endpoint type, and a typed client for each controller the browser calls: one file,
5
- * so a DTO that references another controller's type resolves in place and the client never imports
6
- * from the api. The scripts module is the server-side map a repository passes to createSuiteletClient.
3
+ * Writes the generated modules. Each controller gets its own client module, `<name>.gen.ts`: the
4
+ * entity types it names (copied in, so the module stands on its own), its wire shapes, its endpoint
5
+ * type and, when the browser calls it, its client. The index module re-exports each one as a
6
+ * namespace, so a hook writes `user.api.roles()` and names a shape as `user.RolesResponse`. The
7
+ * scripts module is the server-side map a repository passes to createSuiteletClient.
7
8
  */
9
+ /** The declarations copied from one inlined type file. */
10
+ export interface InlinedTypeSection {
11
+ /** Where the declarations come from: the file's path relative to the project. */
12
+ sourceLabel: string;
13
+ declarations: TypeDeclaration[];
14
+ }
8
15
  export interface EmittedController {
9
16
  contract: ControllerContract;
10
17
  /** Where the file's header points a reader: the controller's path relative to the project. */
11
18
  sourceLabel: string;
19
+ inlinedTypes: InlinedTypeSection[];
12
20
  }
13
- export interface EmitClientModuleOptions {
21
+ export interface EmitControllerModuleOptions {
14
22
  clientModule: string;
23
+ }
24
+ export interface EmitClientIndexModuleOptions {
15
25
  /** How the header names the sources: `api/src/controllers`. */
16
26
  controllersLabel: string;
17
27
  }
@@ -23,7 +33,14 @@ export interface EmitAppModuleOptions {
23
33
  /** How the header names the app file: `netsuite.ts`. */
24
34
  appLabel: string;
25
35
  }
26
- export declare function emitClientModule(controllers: EmittedController[], options: EmitClientModuleOptions): string;
36
+ /** The index module of the client, next to the controller modules: what a hook imports. */
37
+ export declare const CLIENT_INDEX_FILE_NAME = "index.gen.ts";
38
+ /** The client module of a controller: `user.gen.ts` for the user controller. */
39
+ export declare function controllerModuleFileName(controllerName: string): string;
40
+ export declare function emitControllerModule({ contract, sourceLabel, inlinedTypes }: EmittedController, options: EmitControllerModuleOptions): string;
41
+ export declare function sortControllers(controllers: EmittedController[]): EmittedController[];
42
+ /** The index module: every controller module re-exported under the controller's name. */
43
+ export declare function emitClientIndexModule(controllers: EmittedController[], options: EmitClientIndexModuleOptions): string;
27
44
  /** The app file as the client sees it: its exported declarations, verbatim. */
28
45
  export declare function emitAppModule(appDeclarations: string[], options: EmitAppModuleOptions): string;
29
46
  export declare function emitScriptsModule(controllers: EmittedController[], options: EmitScriptsModuleOptions): string;
@@ -1,5 +1,13 @@
1
+ import { GENERATED_CLIENT_NAME, GENERATED_ENDPOINTS_TYPE_NAME } from './controllerReader.js';
2
+ /** The index module of the client, next to the controller modules: what a hook imports. */
3
+ export const CLIENT_INDEX_FILE_NAME = 'index.gen.ts';
4
+ /** The client module of a controller: `user.gen.ts` for the user controller. */
5
+ export function controllerModuleFileName(controllerName) {
6
+ return `${controllerName}.gen.ts`;
7
+ }
1
8
  const INDENT = ' ';
2
9
  const EDIT_NOTICE = 'Do not edit: change the controller and run `npm run generate`.';
10
+ const ESLINT_DISABLE = '/* eslint-disable */';
3
11
  function indentJsDoc(jsDoc) {
4
12
  return jsDoc
5
13
  .split('\n')
@@ -11,54 +19,76 @@ function emitEndpointMember(endpoint) {
11
19
  const member = `${INDENT}${endpoint.name}: (${parameter}) => ${endpoint.responseType};`;
12
20
  return endpoint.jsDoc ? `${indentJsDoc(endpoint.jsDoc)}\n${member}` : member;
13
21
  }
14
- function emitTypeImports(controllers) {
22
+ function formatImportName(imported) {
23
+ return imported.alias ? `${imported.name} as ${imported.alias}` : imported.name;
24
+ }
25
+ /** One `import type` per module, names merged and sorted, modules sorted. */
26
+ function emitTypeImports(imports) {
15
27
  const namesByModule = new Map();
16
- for (const { contract } of controllers) {
17
- for (const typeImport of contract.typeImports) {
18
- const names = namesByModule.get(typeImport.moduleSpecifier) ?? new Set();
19
- typeImport.names.forEach((name) => names.add(name));
20
- namesByModule.set(typeImport.moduleSpecifier, names);
21
- }
28
+ for (const typeImport of imports) {
29
+ const names = namesByModule.get(typeImport.moduleSpecifier) ?? new Set();
30
+ typeImport.names.forEach((imported) => names.add(formatImportName(imported)));
31
+ namesByModule.set(typeImport.moduleSpecifier, names);
22
32
  }
23
33
  return Array.from(namesByModule.keys())
24
34
  .sort()
25
35
  .map((moduleSpecifier) => `import type { ${Array.from(namesByModule.get(moduleSpecifier) ?? []).sort().join(', ')} } from '${moduleSpecifier}';`);
26
36
  }
27
- /** The script reference as a literal, as the client module and the scripts module write it. */
37
+ /** The script reference as a literal, as the controller module and the scripts module write it. */
28
38
  function emitScriptRef(script) {
29
39
  const browser = script.browser ? '' : ', browser: false';
30
40
  return `{ kind: '${script.kind}', scriptId: '${script.scriptId}', deployId: '${script.deployId}'${browser} }`;
31
41
  }
32
- function emitController({ contract, sourceLabel }) {
42
+ export function emitControllerModule({ contract, sourceLabel, inlinedTypes }, options) {
33
43
  const browser = contract.script.browser;
34
- const sections = [`// ${contract.name} (${sourceLabel})${browser ? '' : ': called by server code only, so types only'}`];
44
+ const header = [
45
+ `// Generated by netsuite-api generate from ${sourceLabel}. ${EDIT_NOTICE}`,
46
+ ...(browser ? [] : ['// Called by server code only: its types, and no client.']),
47
+ ESLINT_DISABLE,
48
+ ];
49
+ const imports = [
50
+ ...(browser ? [`import { createApiClient } from '${options.clientModule}';`] : []),
51
+ ...emitTypeImports([
52
+ ...contract.carriedTypeImports,
53
+ ...contract.controllerTypeImports.map((typeImport) => ({ moduleSpecifier: `./${controllerModuleFileName(typeImport.controllerName).replace(/\.ts$/, '')}`, names: typeImport.names })),
54
+ ]),
55
+ ];
56
+ const sections = [header.join('\n'), imports.join('\n')];
57
+ for (const section of inlinedTypes) {
58
+ sections.push(`// Entity types from ${section.sourceLabel}, copied so this module stands on its own.`);
59
+ for (const declaration of section.declarations)
60
+ sections.push(declaration.text);
61
+ }
35
62
  for (const declaration of contract.typeDeclarations)
36
63
  sections.push(declaration.text);
37
64
  const members = contract.endpoints.map(emitEndpointMember);
38
65
  // A type alias, not an interface: only an object type literal satisfies the Endpoints index signature.
39
- sections.push(`export type ${contract.endpointsTypeName} = {\n${members.join('\n')}\n};`);
66
+ sections.push(`/** The endpoint signatures of the ${contract.name} controller, as its handlers declare them. */\nexport type ${GENERATED_ENDPOINTS_TYPE_NAME} = {\n${members.join('\n')}\n};`);
40
67
  if (browser) {
41
68
  const rawEndpoints = contract.endpoints.filter((endpoint) => endpoint.raw).map((endpoint) => `'${endpoint.name}'`);
42
69
  const clientOptions = rawEndpoints.length > 0 ? `, { rawEndpoints: [${rawEndpoints.join(', ')}] }` : '';
43
- sections.push(`/** One typed function per endpoint of the ${contract.name} controller: \`${contract.clientName}.${contract.endpoints[0]?.name ?? 'endpoint'}(...)\`. */\n` +
44
- `export const ${contract.clientName} = createApiClient<${contract.endpointsTypeName}>(${emitScriptRef({ ...contract.script, browser: true })}${clientOptions});`);
70
+ sections.push(`/** One typed function per endpoint of the ${contract.name} controller: \`${contract.name}.${GENERATED_CLIENT_NAME}.${contract.endpoints[0]?.name ?? 'endpoint'}(...)\`. */\n` +
71
+ `export const ${GENERATED_CLIENT_NAME} = createApiClient<${GENERATED_ENDPOINTS_TYPE_NAME}>(${emitScriptRef({ ...contract.script, browser: true })}${clientOptions});`);
45
72
  }
46
- return sections.join('\n\n');
73
+ return `${sections.filter((section) => section !== '').join('\n\n')}\n`;
47
74
  }
48
- function sortControllers(controllers) {
75
+ export function sortControllers(controllers) {
49
76
  return [...controllers].sort((left, right) => left.contract.name.localeCompare(right.contract.name));
50
77
  }
51
- export function emitClientModule(controllers, options) {
52
- const ordered = sortControllers(controllers);
53
- const anyBrowserFacing = ordered.some((controller) => controller.contract.script.browser);
54
- const header = [`// Generated by netsuite-api generate from ${options.controllersLabel}. ${EDIT_NOTICE}`, '/* eslint-disable */'];
55
- const imports = [...(anyBrowserFacing ? [`import { createApiClient } from '${options.clientModule}';`] : []), ...emitTypeImports(ordered)];
56
- const body = ordered.map(emitController);
57
- return `${[header.join('\n'), imports.join('\n'), ...body].filter((section) => section !== '').join('\n\n')}\n`;
78
+ /** The index module: every controller module re-exported under the controller's name. */
79
+ export function emitClientIndexModule(controllers, options) {
80
+ const lines = [`// Generated by netsuite-api generate from ${options.controllersLabel}. Do not edit: change the controllers and run \`npm run generate\`.`, ESLINT_DISABLE, ''];
81
+ for (const { contract } of sortControllers(controllers)) {
82
+ const summary = contract.script.browser
83
+ ? `The ${contract.name} controller: its request and response types, and \`${contract.name}.${GENERATED_CLIENT_NAME}\`, one typed function per endpoint.`
84
+ : `The ${contract.name} controller: its request and response types only; server code calls it, the browser does not.`;
85
+ lines.push(`/** ${summary} */`, `export * as ${contract.name} from './${controllerModuleFileName(contract.name).replace(/\.ts$/, '')}';`);
86
+ }
87
+ return `${lines.join('\n')}\n`;
58
88
  }
59
89
  /** The app file as the client sees it: its exported declarations, verbatim. */
60
90
  export function emitAppModule(appDeclarations, options) {
61
- const header = [`// Generated by netsuite-api generate from ${options.appLabel}. Do not edit: change ${options.appLabel} and run \`npm run generate\`.`, '/* eslint-disable */'];
91
+ const header = [`// Generated by netsuite-api generate from ${options.appLabel}. Do not edit: change ${options.appLabel} and run \`npm run generate\`.`, ESLINT_DISABLE];
62
92
  return `${[header.join('\n'), ...appDeclarations].join('\n\n')}\n`;
63
93
  }
64
94
  export function emitScriptsModule(controllers, options) {
@@ -66,7 +96,7 @@ export function emitScriptsModule(controllers, options) {
66
96
  const entries = ordered.map(({ contract }) => `${INDENT}${contract.name}: ${emitScriptRef(contract.script)},`);
67
97
  return [
68
98
  `// Generated by netsuite-api generate from ${options.controllersLabel}. ${EDIT_NOTICE}`,
69
- '/* eslint-disable */',
99
+ ESLINT_DISABLE,
70
100
  '',
71
101
  `import type { ScriptRef } from '${options.wireModule}';`,
72
102
  '',
@@ -2,6 +2,8 @@
2
2
  export interface FileSystemAdapter {
3
3
  readTextFile(filePath: string): string;
4
4
  writeTextFile(filePath: string, content: string): void;
5
+ /** Removes a file; a file that does not exist is nothing to do. */
6
+ deleteFile(filePath: string): void;
5
7
  fileExists(filePath: string): boolean;
6
8
  ensureDirectory(directoryPath: string): void;
7
9
  /** Lists the absolute paths of the files directly inside a directory. Returns [] when it does not exist. */
@@ -7,6 +7,7 @@ export function createNodeFileSystemAdapter() {
7
7
  return {
8
8
  readTextFile: (filePath) => nodeFileSystem.readFileSync(filePath, 'utf8'),
9
9
  writeTextFile: (filePath, content) => nodeFileSystem.writeFileSync(filePath, content, 'utf8'),
10
+ deleteFile: (filePath) => nodeFileSystem.rmSync(filePath, { force: true }),
10
11
  fileExists: (filePath) => nodeFileSystem.existsSync(filePath),
11
12
  ensureDirectory: (directoryPath) => nodeFileSystem.mkdirSync(directoryPath, { recursive: true }),
12
13
  listFiles: (directoryPath) => {
@@ -39,6 +40,9 @@ export function createInMemoryFileSystemAdapter(initialFiles = {}) {
39
40
  writeTextFile: (filePath, content) => {
40
41
  files.set(normalizeKey(filePath), content);
41
42
  },
43
+ deleteFile: (filePath) => {
44
+ files.delete(normalizeKey(filePath));
45
+ },
42
46
  fileExists: (filePath) => files.has(normalizeKey(filePath)) || directories.has(normalizeKey(filePath)),
43
47
  ensureDirectory: (directoryPath) => {
44
48
  directories.add(normalizeKey(directoryPath));
@@ -18,14 +18,17 @@ export interface PlannedFile {
18
18
  content: string;
19
19
  }
20
20
  export interface ClientGenerationPlan {
21
- /** The modules to write and the files to copy; empty when there are problems. */
21
+ /** The modules to write: one per controller, the client index, the app module, the scripts map; empty when there are problems. */
22
22
  files: PlannedFile[];
23
+ /** 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
+ leftoverFiles: string[];
23
25
  controllers: PlannedController[];
24
26
  problems: ControllerProblem[];
25
27
  }
26
28
  export interface ClientGenerationResult extends ClientGenerationPlan {
27
29
  writtenFiles: string[];
28
30
  unchangedFiles: string[];
31
+ deletedFiles: string[];
29
32
  }
30
33
  export interface ClientGenerationCheck extends ClientGenerationPlan {
31
34
  missingFiles: string[];
@@ -1,31 +1,13 @@
1
1
  import * as nodePath from 'node:path';
2
2
  import { readAppDeclarations } from './appReader.js';
3
3
  import { isControllerFileName, readControllerContract } from './controllerReader.js';
4
- import { emitAppModule, emitClientModule, emitScriptsModule } from './emit.js';
4
+ import { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitAppModule, emitClientIndexModule, emitControllerModule, emitScriptsModule, sortControllers } from './emit.js';
5
5
  import { toPosixPath } from './file-system.js';
6
+ import { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
7
+ const generatedFileNamePattern = /\.gen\.ts$/;
6
8
  function relativeLabel(rootDirectory, filePath) {
7
9
  return toPosixPath(nodePath.relative(rootDirectory, filePath));
8
10
  }
9
- function findDuplicateNames(controllers) {
10
- const owners = new Map();
11
- const problems = [];
12
- const claim = (name, filePath, what) => {
13
- const owner = owners.get(name);
14
- if (owner !== undefined && owner !== filePath) {
15
- problems.push({ filePath, message: `${what} '${name}' is also declared by ${owner}; the generated module holds every controller, so names are unique across them.` });
16
- }
17
- else {
18
- owners.set(name, filePath);
19
- }
20
- };
21
- for (const { contract } of controllers) {
22
- for (const declaration of contract.typeDeclarations)
23
- claim(declaration.name, contract.filePath, 'type');
24
- claim(contract.endpointsTypeName, contract.filePath, 'type');
25
- claim(contract.clientName, contract.filePath, 'client');
26
- }
27
- return problems;
28
- }
29
11
  function findDuplicateScriptIds(controllers) {
30
12
  const problems = [];
31
13
  for (const property of ['scriptId', 'deployId']) {
@@ -41,24 +23,70 @@ function findDuplicateScriptIds(controllers) {
41
23
  }
42
24
  return problems;
43
25
  }
26
+ /** Every generated file in the output directory that is not one of the given paths. */
27
+ function findLeftoverFiles(fileSystem, outDirectory, plannedPaths) {
28
+ const planned = new Set(plannedPaths.map((filePath) => toPosixPath(nodePath.resolve(filePath))));
29
+ return fileSystem
30
+ .listFiles(outDirectory)
31
+ .filter((filePath) => generatedFileNamePattern.test(nodePath.basename(filePath)) && !planned.has(toPosixPath(nodePath.resolve(filePath))));
32
+ }
44
33
  export function planClientGeneration({ config, fileSystem }) {
45
34
  const resolve = (relativePath) => nodePath.resolve(config.rootDirectory, relativePath);
46
35
  const label = (absolutePath) => relativeLabel(config.rootDirectory, absolutePath);
47
36
  const controllersDirectory = resolve(config.controllers);
48
37
  const appFile = resolve(config.appFile);
38
+ const outDirectory = resolve(config.outDir);
49
39
  const problems = [];
50
40
  const controllerFiles = fileSystem.listFiles(controllersDirectory).filter((filePath) => isControllerFileName(nodePath.basename(filePath)));
51
41
  if (controllerFiles.length === 0) {
52
42
  problems.push({ filePath: label(controllersDirectory), message: 'holds no <name>Controller.ts file.' });
53
43
  }
44
+ const controllerNames = new Set(controllerFiles.map((filePath) => nodePath.basename(filePath).replace(/Controller\.ts$/, '')));
45
+ const inlinableFiles = new Map();
46
+ const readInlinable = (specifier) => {
47
+ if (inlinableFiles.has(specifier))
48
+ return inlinableFiles.get(specifier);
49
+ const filePath = resolve(config.inlineTypes[specifier]);
50
+ let file;
51
+ if (fileSystem.fileExists(filePath))
52
+ file = readInlinableTypesFile(label(filePath), fileSystem.readTextFile(filePath));
53
+ else
54
+ problems.push({ filePath: label(filePath), message: 'the file to copy types from does not exist; run the model generator first.' });
55
+ inlinableFiles.set(specifier, file);
56
+ return file;
57
+ };
54
58
  const emitted = [];
55
59
  for (const filePath of controllerFiles) {
56
- const result = readControllerContract(label(filePath), fileSystem.readTextFile(filePath), { typeImports: config.typeImports });
60
+ const controllerLabel = label(filePath);
61
+ const result = readControllerContract(controllerLabel, fileSystem.readTextFile(filePath), { typeImports: config.typeImports, inlineTypes: config.inlineTypes });
57
62
  problems.push(...result.problems);
58
- if (result.contract)
59
- emitted.push({ contract: result.contract, sourceLabel: label(filePath) });
63
+ const contract = result.contract;
64
+ if (!contract)
65
+ continue;
66
+ if (controllerModuleFileName(contract.name) === CLIENT_INDEX_FILE_NAME) {
67
+ problems.push({ filePath: controllerLabel, message: `a controller named '${contract.name}' would take the client's ${CLIENT_INDEX_FILE_NAME}; name it something else.` });
68
+ }
69
+ for (const typeImport of contract.controllerTypeImports) {
70
+ if (!controllerNames.has(typeImport.controllerName)) {
71
+ problems.push({ filePath: controllerLabel, message: `imports types from './${typeImport.controllerName}Controller', which is not a controller in ${label(controllersDirectory)}.` });
72
+ }
73
+ }
74
+ const inlinedTypes = [];
75
+ for (const typeImport of contract.inlinedTypeImports) {
76
+ const file = readInlinable(typeImport.specifier);
77
+ if (!file)
78
+ continue;
79
+ const selected = selectInlinedTypes(file, typeImport.names, controllerLabel);
80
+ problems.push(...selected.problems);
81
+ const section = inlinedTypes.find((existing) => existing.sourceLabel === file.filePath);
82
+ if (section)
83
+ section.declarations.push(...selected.declarations.filter((declaration) => !section.declarations.some((existing) => existing.name === declaration.name)));
84
+ else
85
+ inlinedTypes.push({ sourceLabel: file.filePath, declarations: selected.declarations });
86
+ }
87
+ emitted.push({ contract, sourceLabel: controllerLabel, inlinedTypes });
60
88
  }
61
- problems.push(...findDuplicateNames(emitted), ...findDuplicateScriptIds(emitted));
89
+ problems.push(...findDuplicateScriptIds(emitted));
62
90
  let appDeclarations = [];
63
91
  if (fileSystem.fileExists(appFile)) {
64
92
  const app = readAppDeclarations(label(appFile), fileSystem.readTextFile(appFile));
@@ -68,15 +96,6 @@ export function planClientGeneration({ config, fileSystem }) {
68
96
  else {
69
97
  problems.push({ filePath: label(appFile), message: 'the app file does not exist.' });
70
98
  }
71
- const copies = [];
72
- for (const [source, destination] of Object.entries(config.copyFiles)) {
73
- const sourcePath = resolve(source);
74
- if (!fileSystem.fileExists(sourcePath)) {
75
- problems.push({ filePath: label(sourcePath), message: 'the file to copy into the client does not exist; run the model generator first.' });
76
- continue;
77
- }
78
- copies.push({ path: resolve(destination), content: fileSystem.readTextFile(sourcePath) });
79
- }
80
99
  const controllers = emitted.map(({ contract }) => ({
81
100
  name: contract.name,
82
101
  filePath: contract.filePath,
@@ -85,22 +104,27 @@ export function planClientGeneration({ config, fileSystem }) {
85
104
  endpointCount: contract.endpoints.length,
86
105
  }));
87
106
  if (problems.length > 0)
88
- return { files: [], controllers, problems };
107
+ return { files: [], leftoverFiles: [], controllers, problems };
89
108
  const controllersLabel = label(controllersDirectory);
90
109
  const files = [
91
- { path: resolve(config.outFile), content: emitClientModule(emitted, { clientModule: config.clientModule, controllersLabel }) },
110
+ ...sortControllers(emitted).map((controller) => ({
111
+ path: nodePath.join(outDirectory, controllerModuleFileName(controller.contract.name)),
112
+ content: emitControllerModule(controller, { clientModule: config.clientModule }),
113
+ })),
114
+ { path: nodePath.join(outDirectory, CLIENT_INDEX_FILE_NAME), content: emitClientIndexModule(emitted, { controllersLabel }) },
92
115
  { path: resolve(config.appOutFile), content: emitAppModule(appDeclarations, { appLabel: label(appFile) }) },
93
116
  { path: resolve(config.scriptsOutFile), content: emitScriptsModule(emitted, { wireModule: config.wireModule, controllersLabel }) },
94
- ...copies,
95
117
  ];
96
- return { files, controllers, problems };
118
+ const leftoverFiles = findLeftoverFiles(fileSystem, outDirectory, files.map((file) => file.path));
119
+ return { files, leftoverFiles, controllers, problems };
97
120
  }
98
121
  export function runClientGeneration(options) {
99
122
  const plan = planClientGeneration(options);
100
123
  const writtenFiles = [];
101
124
  const unchangedFiles = [];
125
+ const deletedFiles = [];
102
126
  if (plan.problems.length > 0)
103
- return { ...plan, writtenFiles, unchangedFiles };
127
+ return { ...plan, writtenFiles, unchangedFiles, deletedFiles };
104
128
  const { fileSystem } = options;
105
129
  for (const file of plan.files) {
106
130
  if (fileSystem.fileExists(file.path) && fileSystem.readTextFile(file.path) === file.content) {
@@ -111,7 +135,11 @@ export function runClientGeneration(options) {
111
135
  fileSystem.writeTextFile(file.path, file.content);
112
136
  writtenFiles.push(file.path);
113
137
  }
114
- return { ...plan, writtenFiles, unchangedFiles };
138
+ for (const filePath of plan.leftoverFiles) {
139
+ fileSystem.deleteFile(filePath);
140
+ deletedFiles.push(filePath);
141
+ }
142
+ return { ...plan, writtenFiles, unchangedFiles, deletedFiles };
115
143
  }
116
144
  export function checkClientGeneration(options) {
117
145
  const plan = planClientGeneration(options);
@@ -1,12 +1,14 @@
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
3
  export type { ClientGeneratorConfig, ResolvedClientGeneratorConfig } from './config.js';
4
- export { RAW_RESPONSE_TYPE_NAME, isControllerFileName, readControllerContract, readLeadingJsDoc, toPascalCase } from './controllerReader.js';
5
- export type { CarriedTypeImport, ControllerContract, ControllerKind, ControllerProblem, ControllerReadResult, DeclaredScript, EndpointSignature, ReadControllerOptions, TypeDeclaration } from './controllerReader.js';
4
+ export { GENERATED_CLIENT_NAME, GENERATED_ENDPOINTS_TYPE_NAME, RAW_RESPONSE_TYPE_NAME, isControllerFileName, readControllerContract, readLeadingJsDoc } from './controllerReader.js';
5
+ export type { CarriedTypeImport, ControllerContract, ControllerKind, ControllerProblem, ControllerReadResult, ControllerTypeImport, DeclaredScript, EndpointSignature, InlinedTypeImport, ReadControllerOptions, TypeDeclaration, TypeImportName } from './controllerReader.js';
6
+ export { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
7
+ export type { InlinableTypeDeclaration, InlinableTypesFile, SelectedInlinedTypes } from './typesFileReader.js';
6
8
  export { readAppDeclarations } from './appReader.js';
7
9
  export type { AppDeclarations } from './appReader.js';
8
- export { emitAppModule, emitClientModule, emitScriptsModule } from './emit.js';
9
- export type { EmitAppModuleOptions, EmitClientModuleOptions, EmitScriptsModuleOptions, EmittedController } from './emit.js';
10
+ export { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitAppModule, emitClientIndexModule, emitControllerModule, emitScriptsModule } from './emit.js';
11
+ export type { EmitAppModuleOptions, EmitClientIndexModuleOptions, EmitControllerModuleOptions, EmitScriptsModuleOptions, EmittedController, InlinedTypeSection } from './emit.js';
10
12
  export { checkClientGeneration, planClientGeneration, runClientGeneration } from './generate.js';
11
13
  export type { ClientGenerationCheck, ClientGenerationPlan, ClientGenerationResult, GenerateClientOptions, PlannedController, PlannedFile } from './generate.js';
12
14
  export { createInMemoryFileSystemAdapter, createNodeFileSystemAdapter, toPosixPath } from './file-system.js';
@@ -1,8 +1,9 @@
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 { RAW_RESPONSE_TYPE_NAME, isControllerFileName, readControllerContract, readLeadingJsDoc, toPascalCase } from './controllerReader.js';
3
+ export { GENERATED_CLIENT_NAME, GENERATED_ENDPOINTS_TYPE_NAME, RAW_RESPONSE_TYPE_NAME, isControllerFileName, readControllerContract, readLeadingJsDoc } from './controllerReader.js';
4
+ export { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
4
5
  export { readAppDeclarations } from './appReader.js';
5
- export { emitAppModule, emitClientModule, emitScriptsModule } from './emit.js';
6
+ export { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitAppModule, emitClientIndexModule, emitControllerModule, emitScriptsModule } from './emit.js';
6
7
  export { checkClientGeneration, planClientGeneration, runClientGeneration } from './generate.js';
7
8
  export { createInMemoryFileSystemAdapter, createNodeFileSystemAdapter, toPosixPath } from './file-system.js';
8
9
  export { CLI_USAGE, EXIT_PROBLEMS, EXIT_SUCCESS, EXIT_USAGE, runCli } from './cli/main.js';
@@ -0,0 +1,31 @@
1
+ import type { ControllerProblem, TypeDeclaration, TypeImportName } from './controllerReader.js';
2
+ /**
3
+ * Reads a type-only file whose declarations the generator copies into a controller's generated
4
+ * module: the generated entity types. A controller names the types it imports from the file; each
5
+ * of those is copied with every type it refers to in the same file, so the generated module stands
6
+ * on its own. A type built on something the file imports (the repository package's EntityCreate and
7
+ * EntityPatch) cannot be copied, because the client would need that package to resolve it.
8
+ */
9
+ export interface InlinableTypeDeclaration extends TypeDeclaration {
10
+ /** The names of the types the declaration refers to, each once, in the order met. */
11
+ references: string[];
12
+ }
13
+ export interface InlinableTypesFile {
14
+ /** How a problem names the file: its path relative to the project. */
15
+ filePath: string;
16
+ /** Every top-level type declaration by name, in file order. */
17
+ declarations: Map<string, InlinableTypeDeclaration>;
18
+ /** Every name the file imports, mapped to the module it comes from. */
19
+ importedNames: Map<string, string>;
20
+ }
21
+ export interface SelectedInlinedTypes {
22
+ /** The declarations to copy, in file order, followed by an alias for every renamed import. */
23
+ declarations: TypeDeclaration[];
24
+ problems: ControllerProblem[];
25
+ }
26
+ export declare function readInlinableTypesFile(filePath: string, source: string): InlinableTypesFile;
27
+ /**
28
+ * The declarations a controller's imports pull out of the file: the named types and, transitively,
29
+ * what they refer to. A name the file does not declare is a problem reported against the controller.
30
+ */
31
+ export declare function selectInlinedTypes(file: InlinableTypesFile, names: TypeImportName[], controllerPath: string): SelectedInlinedTypes;