@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.
Files changed (38) hide show
  1. package/README.md +89 -11
  2. package/dist/client/apiClient.js +2 -2
  3. package/dist/client/index.d.ts +1 -1
  4. package/dist/index.d.ts +143 -1
  5. package/dist/server/apiError.d.ts +2 -0
  6. package/dist/server/apiError.js +4 -0
  7. package/dist/server/defineJob.d.ts +95 -0
  8. package/dist/server/defineJob.js +150 -0
  9. package/dist/server/endpoint.js +1 -1
  10. package/dist/server/index.d.ts +8 -3
  11. package/dist/server/index.js +5 -2
  12. package/dist/server/jobRuns.d.ts +65 -0
  13. package/dist/server/jobRuns.js +292 -0
  14. package/dist/server/suiteletClient.js +2 -1
  15. package/dist/testing/N/task.d.ts +14 -0
  16. package/dist/testing/N/task.js +4 -1
  17. package/dist/testing/index.d.ts +3 -0
  18. package/dist/testing/index.js +2 -0
  19. package/dist/testing/jobs.d.ts +50 -0
  20. package/dist/testing/jobs.js +90 -0
  21. package/dist-tooling/cli/main.js +6 -3
  22. package/dist-tooling/config.d.ts +45 -4
  23. package/dist-tooling/config.js +117 -3
  24. package/dist-tooling/controllerReader.d.ts +24 -2
  25. package/dist-tooling/controllerReader.js +7 -6
  26. package/dist-tooling/emit.d.ts +44 -2
  27. package/dist-tooling/emit.js +132 -7
  28. package/dist-tooling/generate.d.ts +10 -1
  29. package/dist-tooling/generate.js +166 -28
  30. package/dist-tooling/index.d.ts +7 -4
  31. package/dist-tooling/index.js +3 -1
  32. package/dist-tooling/jobReader.d.ts +53 -0
  33. package/dist-tooling/jobReader.js +260 -0
  34. package/dist-tooling/typesFileReader.d.ts +28 -9
  35. package/dist-tooling/typesFileReader.js +73 -41
  36. package/dist-tooling/wireTypes.d.ts +16 -0
  37. package/dist-tooling/wireTypes.js +50 -0
  38. package/package.json +1 -1
@@ -3,9 +3,40 @@ import type { FileSystemAdapter } from './file-system.js';
3
3
  * Settings of `netsuite-api generate`, read from netsuite-api.config.json at the project root. Every
4
4
  * path is relative to the config file; the defaults match the layout create-netsuite-project scaffolds.
5
5
  */
6
+ /** A field of the run record, by the name the package's code uses for it. */
7
+ export type JobRunFieldName = 'job' | 'status' | 'stage' | 'stagePercentComplete' | 'input' | 'result' | 'errors' | 'taskId' | 'deployment' | 'startedBy' | 'startedAt' | 'finishedAt';
8
+ /**
9
+ * What each run field's id ends in, under the configured prefix: `custrecord_<prefix>_jr` plus
10
+ * `_status` is the status field. Short on purpose, because NetSuite caps a field id at 40 characters.
11
+ */
12
+ export declare const JOB_RUN_FIELD_SUFFIXES: Record<JobRunFieldName, string>;
13
+ export declare const netsuiteValueTypeNames: readonly ["text", "integer", "decimal", "checkbox", "date", "select"];
14
+ export type NetsuiteValueTypeName = (typeof netsuiteValueTypeNames)[number];
15
+ /**
16
+ * The run record as this application deployed it. The shape is the package's and the ids are the
17
+ * application's: `npm run add:jobs` writes the record with the application's prefix and puts the same
18
+ * ids here, and the generator copies them into the scripts map for the jobs and the run store to read.
19
+ */
20
+ export interface JobRunsSettings {
21
+ /** The record type's id: `customrecord_<prefix>_job_run`. */
22
+ recordType: string;
23
+ /** What every field id starts with: `custrecord_<prefix>_jr`. */
24
+ fieldPrefix: string;
25
+ /** The id of a single field whose name in the account differs from prefix plus suffix. */
26
+ fields?: Partial<Record<JobRunFieldName, string>>;
27
+ /** Fields this application added to the record, by the name its code uses: what startJob may set and a run reports back. */
28
+ extraFields?: Record<string, {
29
+ id: string;
30
+ type: NetsuiteValueTypeName;
31
+ }>;
32
+ }
6
33
  export interface ClientGeneratorConfig {
7
34
  /** Directory holding the controllers: one `<name>Controller.ts` per script. */
8
35
  controllers: string;
36
+ /** Directory holding the jobs: one `<name>.ts` per Map/Reduce script. A project with no such directory has no jobs. */
37
+ jobs: string;
38
+ /** The run record the jobs' runs live in. Left out until the project has jobs (`npm run add:jobs` adds it). */
39
+ jobRuns?: JobRunsSettings;
9
40
  /** The client's generated directory: one `<name>.gen.ts` per controller (its wire shapes, its endpoint type, its client) and `index.gen.ts` re-exporting each under the controller's name. Nothing else lives there. */
10
41
  outDir: string;
11
42
  /** The generated server-side `scripts` map: what a repository passes to createSuiteletClient. */
@@ -22,10 +53,13 @@ export interface ClientGeneratorConfig {
22
53
  */
23
54
  typeImports: Record<string, string>;
24
55
  /**
25
- * Type-only files whose declarations are copied into the generated module of every controller
26
- * that imports from them, as the specifier written in the controller mapped to the file: the
27
- * generated entity types. A controller module carries the types it names, and what those refer
28
- * to, so the client needs no copy of the file.
56
+ * Files whose type declarations are copied into the generated module of every controller that
57
+ * imports types from them, as the specifier written in the controller mapped to the file: the
58
+ * generated entity types, and the services (a key with one `*` stands for a file name:
59
+ * `../services/*` mapped to `api/src/services/*.ts`). A controller module carries the types it
60
+ * names and what those refer to, following an import from one listed file into another, so the
61
+ * client needs no copy of any of them. Only the type declarations of a file are read; a service's
62
+ * functions are not.
29
63
  */
30
64
  inlineTypes: Record<string, string>;
31
65
  }
@@ -35,10 +69,17 @@ export interface ResolvedClientGeneratorConfig extends ClientGeneratorConfig {
35
69
  }
36
70
  export declare const DEFAULT_CONFIG_FILE_NAME = "netsuite-api.config.json";
37
71
  export declare const defaultClientGeneratorConfig: ClientGeneratorConfig;
72
+ /**
73
+ * The file an inlineTypes entry names for a specifier: the exact key, or a key with one `*` standing
74
+ * for a single path segment, substituted into the value. Undefined when no entry matches.
75
+ */
76
+ export declare function resolveInlineTypesFile(inlineTypes: Record<string, string>, specifier: string): string | undefined;
38
77
  export declare class ClientGeneratorConfigError extends Error {
39
78
  readonly configPath: string;
40
79
  readonly problems: string[];
41
80
  constructor(configPath: string, problems: string[]);
42
81
  }
82
+ /** Every run field's id: the prefix plus its suffix, or the override the config gives it. */
83
+ export declare function resolveJobRunFieldIds(settings: JobRunsSettings): Record<JobRunFieldName, string>;
43
84
  /** Loads the config in the working directory (or the one named), applying the defaults for what it omits. */
44
85
  export declare function loadClientGeneratorConfig(fileSystem: FileSystemAdapter, cwd: string, configPath?: string): ResolvedClientGeneratorConfig;
@@ -1,14 +1,57 @@
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',
8
28
  wireModule: '@amerilux/netsuite-api',
9
29
  typeImports: { '@amerilux/netsuite-api/server': '@amerilux/netsuite-api/client' },
10
- inlineTypes: { '../types/models.gen': 'api/src/types/models.gen.ts' },
30
+ inlineTypes: { '../types/models.gen': 'api/src/types/models.gen.ts', '../services/*': 'api/src/services/*.ts' },
11
31
  };
32
+ const wildcardInlineTypesKeyPattern = /^([^*]*)\*([^*]*)$/;
33
+ const wildcardSegmentPattern = /^[A-Za-z0-9_-]+$/;
34
+ /**
35
+ * The file an inlineTypes entry names for a specifier: the exact key, or a key with one `*` standing
36
+ * for a single path segment, substituted into the value. Undefined when no entry matches.
37
+ */
38
+ export function resolveInlineTypesFile(inlineTypes, specifier) {
39
+ const exact = inlineTypes[specifier];
40
+ if (exact !== undefined)
41
+ return exact;
42
+ for (const [key, filePattern] of Object.entries(inlineTypes)) {
43
+ const wildcard = wildcardInlineTypesKeyPattern.exec(key);
44
+ if (!wildcard)
45
+ continue;
46
+ const [, prefix, suffix] = wildcard;
47
+ if (specifier.length <= prefix.length + suffix.length || !specifier.startsWith(prefix) || !specifier.endsWith(suffix))
48
+ continue;
49
+ const segment = specifier.slice(prefix.length, specifier.length - suffix.length);
50
+ if (wildcardSegmentPattern.test(segment))
51
+ return filePattern.replace('*', segment);
52
+ }
53
+ return undefined;
54
+ }
12
55
  export class ClientGeneratorConfigError extends Error {
13
56
  configPath;
14
57
  problems;
@@ -19,11 +62,71 @@ export class ClientGeneratorConfigError extends Error {
19
62
  this.name = 'ClientGeneratorConfigError';
20
63
  }
21
64
  }
22
- const stringSettings = ['controllers', 'outDir', 'scriptsOutFile', 'clientModule', 'wireModule'];
65
+ const stringSettings = ['controllers', 'jobs', 'outDir', 'scriptsOutFile', 'clientModule', 'wireModule'];
23
66
  const mapSettings = ['typeImports', 'inlineTypes'];
24
67
  function isStringMap(value) {
25
68
  return !!value && typeof value === 'object' && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === 'string' && entry !== '');
26
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
+ }
27
130
  function validateClientGeneratorConfig(raw, configPath) {
28
131
  const problems = [];
29
132
  const config = { ...defaultClientGeneratorConfig, typeImports: { ...defaultClientGeneratorConfig.typeImports }, inlineTypes: { ...defaultClientGeneratorConfig.inlineTypes } };
@@ -45,7 +148,18 @@ function validateClientGeneratorConfig(raw, configPath) {
45
148
  else
46
149
  problems.push(`'${setting}' must be an object of strings.`);
47
150
  }
48
- const known = new Set([...stringSettings, ...mapSettings]);
151
+ for (const [key, filePattern] of Object.entries(config.inlineTypes)) {
152
+ const stars = (key.match(/\*/g) ?? []).length;
153
+ if (stars > 1)
154
+ problems.push(`'inlineTypes' key '${key}' has more than one '*'; one stands for the file name.`);
155
+ else if (stars === 1 && !filePattern.includes('*'))
156
+ problems.push(`'inlineTypes' key '${key}' has a '*' but its file '${filePattern}' has none to put the name in.`);
157
+ else if (stars === 0 && filePattern.includes('*'))
158
+ problems.push(`'inlineTypes' file '${filePattern}' has a '*' but its key '${key}' has none to take the name from.`);
159
+ }
160
+ if (raw.jobRuns !== undefined)
161
+ config.jobRuns = validateJobRunsSettings(raw.jobRuns, problems);
162
+ const known = new Set([...stringSettings, ...mapSettings, 'jobRuns']);
49
163
  for (const key of Object.keys(raw)) {
50
164
  if (!known.has(key))
51
165
  problems.push(`'${key}' is not a setting.`);
@@ -4,8 +4,8 @@ import ts from 'typescript';
4
4
  * defineRestlet or defineSuitelet call, the exported types (the DTOs), and the name, request type and
5
5
  * response type of every endpoint in its `defineEndpoints({ ... })`. A parse, not a type check: the
6
6
  * handler annotations are the contract, so they must be written out, a DTO may only reference types
7
- * from the inlined files, the carried modules or another controller, and the script ids are string
8
- * literals.
7
+ * from the inlined files (the entity types, the services), the carried modules or another controller,
8
+ * and the script ids are string literals.
9
9
  */
10
10
  export interface ControllerProblem {
11
11
  filePath: string;
@@ -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;
@@ -1,5 +1,6 @@
1
1
  import * as nodePath from 'node:path';
2
2
  import ts from 'typescript';
3
+ import { resolveInlineTypesFile } from './config.js';
3
4
  import { toPosixPath } from './file-system.js';
4
5
  /** The return type a handler writes, exactly, to answer with a document instead of the envelope. */
5
6
  export const RAW_RESPONSE_TYPE_NAME = 'RawResponse';
@@ -16,7 +17,7 @@ const entryPointByFunction = {
16
17
  export function isControllerFileName(fileName) {
17
18
  return controllerFileNamePattern.test(fileName);
18
19
  }
19
- function hasExportModifier(node) {
20
+ export function hasExportModifier(node) {
20
21
  return ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
21
22
  }
22
23
  /** The JSDoc block directly above a node (no blank line between them), or undefined. */
@@ -31,11 +32,11 @@ export function readLeadingJsDoc(node, sourceFile) {
31
32
  return sourceFile.text.slice(jsDocRange.pos, jsDocRange.end);
32
33
  }
33
34
  /** The script type NetSuite reads from the leading JSDoc, or undefined. */
34
- function readScriptTypeHeader(source) {
35
+ export function readScriptTypeHeader(source) {
35
36
  const header = source.match(/^\s*(\/\*[\s\S]*?\*\/)/)?.[1] ?? '';
36
37
  return header.match(/@NScriptType\s+(\w+)/)?.[1];
37
38
  }
38
- function readTypeImport(statement, filePath, options) {
39
+ export function readTypeImport(statement, filePath, options) {
39
40
  const problems = [];
40
41
  const clause = statement.importClause;
41
42
  if (!clause || !ts.isStringLiteral(statement.moduleSpecifier))
@@ -63,7 +64,7 @@ function readTypeImport(statement, filePath, options) {
63
64
  const sibling = siblingControllerSpecifierPattern.exec(moduleSpecifier);
64
65
  if (sibling)
65
66
  return { read: { kind: 'controller', typeImport: { controllerName: sibling[1], names } }, problems };
66
- if (options.inlineTypes[moduleSpecifier] !== undefined)
67
+ if (resolveInlineTypesFile(options.inlineTypes, moduleSpecifier) !== undefined)
67
68
  return { read: { kind: 'inlined', typeImport: { specifier: moduleSpecifier, names } }, problems };
68
69
  const clientSpecifier = options.typeImports[moduleSpecifier];
69
70
  if (clientSpecifier !== undefined)
@@ -118,7 +119,7 @@ function readEndpoint(property, filePath, sourceFile) {
118
119
  problems,
119
120
  };
120
121
  }
121
- function findCall(statement, calleeNames) {
122
+ export function findCall(statement, calleeNames) {
122
123
  for (const declaration of statement.declarationList.declarations) {
123
124
  if (!ts.isIdentifier(declaration.name))
124
125
  continue;
@@ -129,7 +130,7 @@ function findCall(statement, calleeNames) {
129
130
  }
130
131
  return undefined;
131
132
  }
132
- function readStringProperty(literal, propertyName) {
133
+ export function readStringProperty(literal, propertyName) {
133
134
  for (const property of literal.properties) {
134
135
  if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === propertyName && ts.isStringLiteral(property.initializer))
135
136
  return property.initializer.text;
@@ -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
- * 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
+ * entity and service types it names (copied in, so the module stands on its own), its wire shapes,
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;
@@ -1,11 +1,21 @@
1
1
  import { GENERATED_CLIENT_NAME, GENERATED_ENDPOINTS_TYPE_NAME } from './controllerReader.js';
2
+ import { GENERATED_JOB_RESULT_TYPE_NAME } from './jobReader.js';
3
+ import { readReferencedNames, writeDatesAsStrings } from './wireTypes.js';
2
4
  /** The index module of the client, next to the controller modules: what a hook imports. */
3
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';
4
8
  /** The client module of a controller: `user.gen.ts` for the user controller. */
5
9
  export function controllerModuleFileName(controllerName) {
6
10
  return `${controllerName}.gen.ts`;
7
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
+ }
8
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']);
9
19
  const EDIT_NOTICE = 'Do not edit: change the controller and run `npm run generate`.';
10
20
  const ESLINT_DISABLE = '/* eslint-disable */';
11
21
  function indentJsDoc(jsDoc) {
@@ -14,9 +24,10 @@ function indentJsDoc(jsDoc) {
14
24
  .map((line, index) => (index === 0 ? `${INDENT}${line.trim()}` : `${INDENT} ${line.trim()}`))
15
25
  .join('\n');
16
26
  }
27
+ // A Date on the wire is an ISO string: the client module says so wherever a shape says Date.
17
28
  function emitEndpointMember(endpoint) {
18
- const parameter = endpoint.requestType === undefined ? '' : `request${endpoint.requestOptional ? '?' : ''}: ${endpoint.requestType}`;
19
- const member = `${INDENT}${endpoint.name}: (${parameter}) => ${endpoint.responseType};`;
29
+ const parameter = endpoint.requestType === undefined ? '' : `request${endpoint.requestOptional ? '?' : ''}: ${writeDatesAsStrings(endpoint.requestType, 'type')}`;
30
+ const member = `${INDENT}${endpoint.name}: (${parameter}) => ${writeDatesAsStrings(endpoint.responseType, 'type')};`;
20
31
  return endpoint.jsDoc ? `${indentJsDoc(endpoint.jsDoc)}\n${member}` : member;
21
32
  }
22
33
  function formatImportName(imported) {
@@ -55,12 +66,12 @@ export function emitControllerModule({ contract, sourceLabel, inlinedTypes }, op
55
66
  ];
56
67
  const sections = [header.join('\n'), imports.join('\n')];
57
68
  for (const section of inlinedTypes) {
58
- sections.push(`// Entity types from ${section.sourceLabel}, copied so this module stands on its own.`);
69
+ sections.push(`// Types from ${section.sourceLabel}, copied so this module stands on its own.`);
59
70
  for (const declaration of section.declarations)
60
- sections.push(declaration.text);
71
+ sections.push(writeDatesAsStrings(declaration.text, 'declaration'));
61
72
  }
62
73
  for (const declaration of contract.typeDeclarations)
63
- sections.push(declaration.text);
74
+ sections.push(writeDatesAsStrings(declaration.text, 'declaration'));
64
75
  const members = contract.endpoints.map(emitEndpointMember);
65
76
  // A type alias, not an interface: only an object type literal satisfies the Endpoints index signature.
66
77
  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};`);
@@ -84,21 +95,135 @@ export function emitClientIndexModule(controllers, options) {
84
95
  : `The ${contract.name} controller: its request and response types only; server code calls it, the browser does not.`;
85
96
  lines.push(`/** ${summary} */`, `export * as ${contract.name} from './${controllerModuleFileName(contract.name).replace(/\.ts$/, '')}';`);
86
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
+ }
87
101
  return `${lines.join('\n')}\n`;
88
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
+ }
89
201
  export function emitScriptsModule(controllers, options) {
90
202
  const ordered = sortControllers(controllers);
91
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 ');
92
207
  return [
93
- `// Generated by netsuite-api generate from ${options.controllersLabel}. ${EDIT_NOTICE}`,
208
+ `// Generated by netsuite-api generate from ${sourceLabels}. ${EDIT_NOTICE}`,
94
209
  ESLINT_DISABLE,
95
210
  '',
96
- `import type { ScriptRef } from '${options.wireModule}';`,
211
+ `import type { ${importedTypes.join(', ')} } from '${options.wireModule}';`,
97
212
  '',
98
213
  '/** Every script the controllers declare, by controller name: what a repository passes to createSuiteletClient. */',
99
214
  'export const scripts = {',
100
215
  ...entries,
101
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)] : []),
102
227
  '',
103
228
  ].join('\n');
104
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 {