@amerilux/netsuite-api 0.2.0 → 0.4.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 CHANGED
@@ -8,7 +8,7 @@ The API layer for a NetSuite single-page app. The app's server side is SuiteScri
8
8
  - **`netsuite-api generate`**: reads the controllers and writes the client's whole view of the backend, one module per controller and an index re-exporting them, plus the server-side map of scripts. The client never imports from the server tree.
9
9
  - **`@amerilux/netsuite-api`** (the root): the wire itself. The envelope, the endpoint types, `ScriptDeclaration`, `ScriptRef`.
10
10
 
11
- The layout it assumes is the one `create-netsuite-project` scaffolds: `api/` (SuiteScript) and `client/` (React) as workspaces, and `netsuite.ts` at the root holding the application's names.
11
+ The layout it assumes is the one `create-netsuite-project` scaffolds: `api/` (SuiteScript) and `client/` (React) as workspaces.
12
12
 
13
13
  ## A controller
14
14
 
@@ -45,7 +45,7 @@ export const post = defineRestlet({
45
45
  }, customerEndpoints);
46
46
  ```
47
47
 
48
- Every call is a POST whose JSON body carries the request plus an `endpoint` property naming the endpoint. The handler answers with data, or throws an `ApiError` for a status the caller should see; anything else is a 500 with the details logged. A Suitelet controller is the same file with `defineSuitelet` and `onRequest` instead, and `browser: false` in the declaration when only server code calls it.
48
+ Every call is a POST whose JSON body carries the request plus an `endpoint` property naming the endpoint. The handler answers with data, or throws an `ApiError` for a status the caller should see, with a `details` object the caller can act on (a per-field validation map, the offending id) that travels in the envelope; anything else is a 500 whose cause is logged and never sent. A Suitelet controller is the same file with `defineSuitelet` and `onRequest` instead, and `browser: false` in the declaration when only server code calls it.
49
49
 
50
50
  The declaration is the controller's own statement of the script it is deployed as. It creates nothing: the controller builds, tests and bundles before any script record exists. The ids are how a client reaches the controller once it is deployed, so set them to whatever the record and deployment are called in NetSuite and in the SDF object; the generator wires every client to them.
51
51
 
@@ -53,7 +53,7 @@ The generator reads the file as source, so a few things are rules rather than co
53
53
 
54
54
  - Handlers are written inline with their parameter and return types annotated. A reference to a service function carries no types the generator can read.
55
55
  - Every type in the file is a wire shape and is exported.
56
- - A type is imported only from the inlined files (the generated entity types, by default), the carried modules (the package's server entry, for `RawResponse`) or another controller. A service's return type is never used as a DTO by reference.
56
+ - A type is imported only from the inlined files (by default the generated entity types and the services: what a service returns is the domain type a wire shape is built from), the carried modules (the package's server entry, for `RawResponse`) or another controller. A service's function is never referenced by the handler in place of an annotation.
57
57
  - The script declaration is an object literal with literal ids, its `name` is the file name without `Controller`, and the entry point export and the `@NScriptType` header agree with the define function.
58
58
  - Script ids are unique across controllers, and no wire shape is named `Endpoints`. A shape's name carries no controller prefix: each controller's generated module is its own namespace.
59
59
 
@@ -67,7 +67,7 @@ The generator reads the file as source, so a few things are rules rather than co
67
67
  // client/src/api/customer.gen.ts
68
68
  import { createApiClient } from '@amerilux/netsuite-api/client';
69
69
 
70
- // Entity types from api/src/types/models.gen.ts, copied so this module stands on its own.
70
+ // Types from api/src/types/models.gen.ts, copied so this module stands on its own.
71
71
 
72
72
  export interface Customer {
73
73
  id: number;
@@ -91,7 +91,9 @@ export type Endpoints = {
91
91
  export const api = createApiClient<Endpoints>({ kind: 'restlet', scriptId: 'customscript_app_customer', deployId: 'customdeploy_app_customer' });
92
92
  ```
93
93
 
94
- A type imported from another controller becomes an import of that controller's module. A type built on something the entity file imports itself (`CustomerCreate`, `CustomerPatch`: the repository package's input types) is an error, because the client could not carry it; write the wire shape out in the controller instead. A generated module no controller owns any more is deleted on the next run.
94
+ A type imported from a service is copied the same way, with the entity types it is built on following it in from the models file. A type imported from another controller becomes an import of that controller's module. A type built on something no inlined file declares (`CustomerCreate`, `CustomerPatch`: the repository package's input types; a type a service imports from a package) is an error, because the client could not carry it; write the wire shape out in the controller instead. So is a type reached through a renamed import (`import type { Employee as EmployeeRecord }`) in a service: import it under its own name. A generated module no controller owns any more is deleted on the next run.
95
+
96
+ JSON carries no dates. A `Date` in a response shape (an entity's `tranDate`) arrives in the browser as an ISO 8601 string, so the generated module writes `string` wherever the shape says `Date`; the page formats it. A `Date` in a request shape is an error, because the handler would receive a string where its annotation promises a Date: take a string and parse it in the handler.
95
97
 
96
98
  **`client/src/api/index.gen.ts`**, the client's view of the backend: every controller's module re-exported under the controller's name.
97
99
 
@@ -102,16 +104,10 @@ export * as user from './user.gen';
102
104
 
103
105
  A hook imports `{ customer }` from it, calls `customer.api.search({ search: 'acme' })` and gets a `Promise<customer.CustomerSummary[]>`. The second argument carries an `AbortSignal`.
104
106
 
105
- **`client/src/app.gen.ts`**, a verbatim copy of the app file, outside the api folder so a page or a component may import `app` without touching a client.
106
-
107
107
  **`api/src/scripts.gen.ts`**, the server-side map of every declared script by controller name. A repository passes an entry to `createSuiteletClient`; nothing else needs it.
108
108
 
109
109
  `netsuite-api check` exits non-zero when any generated file is missing, out of date or left over, for CI. `netsuite-api generate --dry-run` prints every file instead of writing anything.
110
110
 
111
- ### The app file
112
-
113
- `netsuite.ts` at the project root holds the application's names and any id no controller or model owns. Both sides use it, so the client gets a verbatim copy: the file is exported constants and types only, with no imports and nothing that runs.
114
-
115
111
  ### Configuration
116
112
 
117
113
  `netsuite-api.config.json` at the project root, every setting optional. The defaults:
@@ -119,18 +115,16 @@ A hook imports `{ customer }` from it, calls `customer.api.search({ search: 'acm
119
115
  ```json
120
116
  {
121
117
  "controllers": "api/src/controllers",
122
- "appFile": "netsuite.ts",
123
118
  "outDir": "client/src/api",
124
- "appOutFile": "client/src/app.gen.ts",
125
119
  "scriptsOutFile": "api/src/scripts.gen.ts",
126
120
  "clientModule": "@amerilux/netsuite-api/client",
127
121
  "wireModule": "@amerilux/netsuite-api",
128
122
  "typeImports": { "@amerilux/netsuite-api/server": "@amerilux/netsuite-api/client" },
129
- "inlineTypes": { "../types/models.gen": "api/src/types/models.gen.ts" }
123
+ "inlineTypes": { "../types/models.gen": "api/src/types/models.gen.ts", "../services/*": "api/src/services/*.ts" }
130
124
  }
131
125
  ```
132
126
 
133
- Paths are relative to the config file. `outDir` holds the controller modules and the index, and nothing else. `inlineTypes` maps a specifier as written in a controller to the type-only file whose declarations are copied into the module of every controller importing from it. `typeImports` maps a specifier to the one the client resolves, for a type that stays an import (the package's server entry maps to its client entry so `RawResponse` carries over). A type imported from any other module is an error.
127
+ Paths are relative to the config file. `outDir` holds the controller modules and the index, and nothing else. `inlineTypes` maps a specifier as written in a controller to the file whose type declarations are copied into the module of every controller importing from it; a key with one `*` stands for a file name and the `*` in its file takes that name, so `../services/*` covers every service. Only the type declarations of a file are read, so a service's functions are skipped; a type in one inlined file that refers to a type imported from another (a service's summary type built on an entity type) brings that type along, the import resolved through the same map as written from the same folder depth. `typeImports` maps a specifier to the one the client resolves, for a type that stays an import (the package's server entry maps to its client entry so `RawResponse` carries over). A type imported from any other module is an error.
134
128
 
135
129
  ## The client at runtime
136
130
 
@@ -142,7 +136,7 @@ import { configureApiClient } from '@amerilux/netsuite-api/client';
142
136
  if (import.meta.env.DEV) configureApiClient({ basePaths: { restlet: '/api/restlet', suitelet: '/api/suitelet' } });
143
137
  ```
144
138
 
145
- A failed call rejects with an `ApiClientError` carrying the envelope's status and message; a call that got no answer at all carries `NO_RESPONSE_STATUS` (0). Before it rejects, the failure goes to the handler `configureApiClient` was given, with the script, the endpoint and the request, so the app reports every failure in one place and a hook carries no error handling of its own:
139
+ A failed call rejects with an `ApiClientError` carrying the envelope's status, message and `details` (whatever the handler gave its `ApiError`, for a form to show per field); a call that got no answer at all carries `NO_RESPONSE_STATUS` (0). Before it rejects, the failure goes to the handler `configureApiClient` was given, with the script, the endpoint and the request, so the app reports every failure in one place and a hook carries no error handling of its own:
146
140
 
147
141
  ```ts
148
142
  configureApiClient({ onError: (error, { endpoint }) => showBanner(`${endpoint}: ${error.message}`) });
@@ -80,7 +80,7 @@ export function callEndpoint(scriptRef, endpointName, request = {}, options = {}
80
80
  throw new ApiClientError(response.status, `Unexpected response from ${scriptRef.scriptId} (${response.status})`, text.slice(0, 500));
81
81
  }
82
82
  if (envelope.error !== null || envelope.status >= 400) {
83
- throw new ApiClientError(envelope.status, (_a = envelope.error) !== null && _a !== void 0 ? _a : `Request failed (${envelope.status})`);
83
+ throw new ApiClientError(envelope.status, (_a = envelope.error) !== null && _a !== void 0 ? _a : `Request failed (${envelope.status})`, envelope.details);
84
84
  }
85
85
  return envelope.data;
86
86
  });
@@ -99,7 +99,7 @@ export function callRawEndpoint(scriptRef, endpointName, request = {}, options =
99
99
  const text = await response.text();
100
100
  const envelope = parseEnvelope(text);
101
101
  if (envelope && (envelope.error !== null || envelope.status >= 400))
102
- throw new ApiClientError(envelope.status, (_b = envelope.error) !== null && _b !== void 0 ? _b : `Request failed (${envelope.status})`);
102
+ throw new ApiClientError(envelope.status, (_b = envelope.error) !== null && _b !== void 0 ? _b : `Request failed (${envelope.status})`, envelope.details);
103
103
  if (!response.ok)
104
104
  throw new ApiClientError(response.status, `Unexpected response from ${scriptRef.scriptId} (${response.status})`, text.slice(0, 500));
105
105
  throw new ApiClientError(response.status, `${scriptRef.scriptId}.${endpointName} answered JSON where a document was expected.`, text.slice(0, 500));
package/dist/index.d.ts CHANGED
@@ -2,11 +2,16 @@
2
2
  * The wire: what a controller answers with, how a call names its endpoint, and how a script is
3
3
  * reached. Both sides import this module; nothing in it touches NetSuite or the browser.
4
4
  */
5
- /** Every controller answers with this envelope; `data` is null whenever `error` is set. */
5
+ /**
6
+ * Every controller answers with this envelope; `data` is null whenever `error` is set. `details` is
7
+ * whatever the handler gave its ApiError (a per-field validation map, the offending id), sent so the
8
+ * caller can act on it; a 500 carries none, its cause stays in the log.
9
+ */
6
10
  export interface ApiEnvelope<TData> {
7
11
  status: number;
8
12
  error: string | null;
9
13
  data: TData | null;
14
+ details?: unknown;
10
15
  }
11
16
  /** The body of a failed call, as thrown by the client's ApiClientError. */
12
17
  export interface ApiErrorBody {
@@ -83,7 +83,7 @@ export function invokeEndpoint(controllerName, endpoints, rawRequest, options =
83
83
  if (error instanceof ApiError) {
84
84
  status = error.status;
85
85
  log.debug('endpoint rejected', { controller: controllerName, endpoint: endpointName, status, message: error.message, details: error.details });
86
- return { envelope: { status, error: error.message, data: null } };
86
+ return { envelope: error.details === undefined ? { status, error: error.message, data: null } : { status, error: error.message, data: null, details: error.details } };
87
87
  }
88
88
  status = 500;
89
89
  log.error('endpoint failed', { controller: controllerName, endpoint: endpointName, ...describeError(error) });
@@ -35,7 +35,8 @@ export function callSuiteletEndpoint(scriptRef, endpointName, request = {}) {
35
35
  }
36
36
  const envelope = parseEnvelope(scriptRef, endpointName, response.body);
37
37
  if (envelope.error !== null || envelope.status >= 400) {
38
- throw new ApiError(envelope.status, (_a = envelope.error) !== null && _a !== void 0 ? _a : `Request failed (${envelope.status})`, { script: scriptRef.scriptId, endpoint: endpointName });
38
+ // The called script's details travel on, so the caller (and through it the browser) can act on them.
39
+ throw new ApiError(envelope.status, (_a = envelope.error) !== null && _a !== void 0 ? _a : `Request failed (${envelope.status})`, envelope.details !== undefined ? envelope.details : { script: scriptRef.scriptId, endpoint: endpointName });
39
40
  }
40
41
  return envelope.data;
41
42
  }
@@ -6,12 +6,8 @@ import type { FileSystemAdapter } from './file-system.js';
6
6
  export interface ClientGeneratorConfig {
7
7
  /** Directory holding the controllers: one `<name>Controller.ts` per script. */
8
8
  controllers: string;
9
- /** The file declaring `app` (and any other id no controller or model owns), copied into the client verbatim. */
10
- appFile: string;
11
9
  /** 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. */
12
10
  outDir: string;
13
- /** The generated copy of the app file for the client, outside the api folder so pages and components may import it. */
14
- appOutFile: string;
15
11
  /** The generated server-side `scripts` map: what a repository passes to createSuiteletClient. */
16
12
  scriptsOutFile: string;
17
13
  /** The specifier the controller modules import `createApiClient` from. */
@@ -26,10 +22,13 @@ export interface ClientGeneratorConfig {
26
22
  */
27
23
  typeImports: Record<string, string>;
28
24
  /**
29
- * Type-only files whose declarations are copied into the generated module of every controller
30
- * that imports from them, as the specifier written in the controller mapped to the file: the
31
- * generated entity types. A controller module carries the types it names, and what those refer
32
- * to, so the client needs no copy of the file.
25
+ * Files whose type declarations are copied into the generated module of every controller that
26
+ * imports types from them, as the specifier written in the controller mapped to the file: the
27
+ * generated entity types, and the services (a key with one `*` stands for a file name:
28
+ * `../services/*` mapped to `api/src/services/*.ts`). A controller module carries the types it
29
+ * names and what those refer to, following an import from one listed file into another, so the
30
+ * client needs no copy of any of them. Only the type declarations of a file are read; a service's
31
+ * functions are not.
33
32
  */
34
33
  inlineTypes: Record<string, string>;
35
34
  }
@@ -39,6 +38,11 @@ export interface ResolvedClientGeneratorConfig extends ClientGeneratorConfig {
39
38
  }
40
39
  export declare const DEFAULT_CONFIG_FILE_NAME = "netsuite-api.config.json";
41
40
  export declare const defaultClientGeneratorConfig: ClientGeneratorConfig;
41
+ /**
42
+ * The file an inlineTypes entry names for a specifier: the exact key, or a key with one `*` standing
43
+ * for a single path segment, substituted into the value. Undefined when no entry matches.
44
+ */
45
+ export declare function resolveInlineTypesFile(inlineTypes: Record<string, string>, specifier: string): string | undefined;
42
46
  export declare class ClientGeneratorConfigError extends Error {
43
47
  readonly configPath: string;
44
48
  readonly problems: string[];
@@ -2,15 +2,36 @@ import * as nodePath from 'node:path';
2
2
  export const DEFAULT_CONFIG_FILE_NAME = 'netsuite-api.config.json';
3
3
  export const defaultClientGeneratorConfig = {
4
4
  controllers: 'api/src/controllers',
5
- appFile: 'netsuite.ts',
6
5
  outDir: 'client/src/api',
7
- appOutFile: 'client/src/app.gen.ts',
8
6
  scriptsOutFile: 'api/src/scripts.gen.ts',
9
7
  clientModule: '@amerilux/netsuite-api/client',
10
8
  wireModule: '@amerilux/netsuite-api',
11
9
  typeImports: { '@amerilux/netsuite-api/server': '@amerilux/netsuite-api/client' },
12
- inlineTypes: { '../types/models.gen': 'api/src/types/models.gen.ts' },
10
+ inlineTypes: { '../types/models.gen': 'api/src/types/models.gen.ts', '../services/*': 'api/src/services/*.ts' },
13
11
  };
12
+ const wildcardInlineTypesKeyPattern = /^([^*]*)\*([^*]*)$/;
13
+ const wildcardSegmentPattern = /^[A-Za-z0-9_-]+$/;
14
+ /**
15
+ * The file an inlineTypes entry names for a specifier: the exact key, or a key with one `*` standing
16
+ * for a single path segment, substituted into the value. Undefined when no entry matches.
17
+ */
18
+ export function resolveInlineTypesFile(inlineTypes, specifier) {
19
+ const exact = inlineTypes[specifier];
20
+ if (exact !== undefined)
21
+ return exact;
22
+ for (const [key, filePattern] of Object.entries(inlineTypes)) {
23
+ const wildcard = wildcardInlineTypesKeyPattern.exec(key);
24
+ if (!wildcard)
25
+ continue;
26
+ const [, prefix, suffix] = wildcard;
27
+ if (specifier.length <= prefix.length + suffix.length || !specifier.startsWith(prefix) || !specifier.endsWith(suffix))
28
+ continue;
29
+ const segment = specifier.slice(prefix.length, specifier.length - suffix.length);
30
+ if (wildcardSegmentPattern.test(segment))
31
+ return filePattern.replace('*', segment);
32
+ }
33
+ return undefined;
34
+ }
14
35
  export class ClientGeneratorConfigError extends Error {
15
36
  configPath;
16
37
  problems;
@@ -21,7 +42,7 @@ export class ClientGeneratorConfigError extends Error {
21
42
  this.name = 'ClientGeneratorConfigError';
22
43
  }
23
44
  }
24
- const stringSettings = ['controllers', 'appFile', 'outDir', 'appOutFile', 'scriptsOutFile', 'clientModule', 'wireModule'];
45
+ const stringSettings = ['controllers', 'outDir', 'scriptsOutFile', 'clientModule', 'wireModule'];
25
46
  const mapSettings = ['typeImports', 'inlineTypes'];
26
47
  function isStringMap(value) {
27
48
  return !!value && typeof value === 'object' && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === 'string' && entry !== '');
@@ -47,6 +68,15 @@ function validateClientGeneratorConfig(raw, configPath) {
47
68
  else
48
69
  problems.push(`'${setting}' must be an object of strings.`);
49
70
  }
71
+ for (const [key, filePattern] of Object.entries(config.inlineTypes)) {
72
+ const stars = (key.match(/\*/g) ?? []).length;
73
+ if (stars > 1)
74
+ problems.push(`'inlineTypes' key '${key}' has more than one '*'; one stands for the file name.`);
75
+ else if (stars === 1 && !filePattern.includes('*'))
76
+ problems.push(`'inlineTypes' key '${key}' has a '*' but its file '${filePattern}' has none to put the name in.`);
77
+ else if (stars === 0 && filePattern.includes('*'))
78
+ problems.push(`'inlineTypes' file '${filePattern}' has a '*' but its key '${key}' has none to take the name from.`);
79
+ }
50
80
  const known = new Set([...stringSettings, ...mapSettings]);
51
81
  for (const key of Object.keys(raw)) {
52
82
  if (!known.has(key))
@@ -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;
@@ -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';
@@ -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)
@@ -1,8 +1,8 @@
1
1
  import type { ControllerContract, TypeDeclaration } from './controllerReader.js';
2
2
  /**
3
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
4
+ * entity and service types it names (copied in, so the module stands on its own), its wire shapes,
5
+ * its endpoint type and, when the browser calls it, its client. The index module re-exports each one as a
6
6
  * namespace, so a hook writes `user.api.roles()` and names a shape as `user.RolesResponse`. The
7
7
  * scripts module is the server-side map a repository passes to createSuiteletClient.
8
8
  */
@@ -29,10 +29,6 @@ export interface EmitScriptsModuleOptions {
29
29
  wireModule: string;
30
30
  controllersLabel: string;
31
31
  }
32
- export interface EmitAppModuleOptions {
33
- /** How the header names the app file: `netsuite.ts`. */
34
- appLabel: string;
35
- }
36
32
  /** The index module of the client, next to the controller modules: what a hook imports. */
37
33
  export declare const CLIENT_INDEX_FILE_NAME = "index.gen.ts";
38
34
  /** The client module of a controller: `user.gen.ts` for the user controller. */
@@ -41,6 +37,4 @@ export declare function emitControllerModule({ contract, sourceLabel, inlinedTyp
41
37
  export declare function sortControllers(controllers: EmittedController[]): EmittedController[];
42
38
  /** The index module: every controller module re-exported under the controller's name. */
43
39
  export declare function emitClientIndexModule(controllers: EmittedController[], options: EmitClientIndexModuleOptions): string;
44
- /** The app file as the client sees it: its exported declarations, verbatim. */
45
- export declare function emitAppModule(appDeclarations: string[], options: EmitAppModuleOptions): string;
46
40
  export declare function emitScriptsModule(controllers: EmittedController[], options: EmitScriptsModuleOptions): string;
@@ -1,4 +1,5 @@
1
1
  import { GENERATED_CLIENT_NAME, GENERATED_ENDPOINTS_TYPE_NAME } from './controllerReader.js';
2
+ import { writeDatesAsStrings } from './wireTypes.js';
2
3
  /** The index module of the client, next to the controller modules: what a hook imports. */
3
4
  export const CLIENT_INDEX_FILE_NAME = 'index.gen.ts';
4
5
  /** The client module of a controller: `user.gen.ts` for the user controller. */
@@ -14,9 +15,10 @@ function indentJsDoc(jsDoc) {
14
15
  .map((line, index) => (index === 0 ? `${INDENT}${line.trim()}` : `${INDENT} ${line.trim()}`))
15
16
  .join('\n');
16
17
  }
18
+ // A Date on the wire is an ISO string: the client module says so wherever a shape says Date.
17
19
  function emitEndpointMember(endpoint) {
18
- const parameter = endpoint.requestType === undefined ? '' : `request${endpoint.requestOptional ? '?' : ''}: ${endpoint.requestType}`;
19
- const member = `${INDENT}${endpoint.name}: (${parameter}) => ${endpoint.responseType};`;
20
+ const parameter = endpoint.requestType === undefined ? '' : `request${endpoint.requestOptional ? '?' : ''}: ${writeDatesAsStrings(endpoint.requestType, 'type')}`;
21
+ const member = `${INDENT}${endpoint.name}: (${parameter}) => ${writeDatesAsStrings(endpoint.responseType, 'type')};`;
20
22
  return endpoint.jsDoc ? `${indentJsDoc(endpoint.jsDoc)}\n${member}` : member;
21
23
  }
22
24
  function formatImportName(imported) {
@@ -55,12 +57,12 @@ export function emitControllerModule({ contract, sourceLabel, inlinedTypes }, op
55
57
  ];
56
58
  const sections = [header.join('\n'), imports.join('\n')];
57
59
  for (const section of inlinedTypes) {
58
- sections.push(`// Entity types from ${section.sourceLabel}, copied so this module stands on its own.`);
60
+ sections.push(`// Types from ${section.sourceLabel}, copied so this module stands on its own.`);
59
61
  for (const declaration of section.declarations)
60
- sections.push(declaration.text);
62
+ sections.push(writeDatesAsStrings(declaration.text, 'declaration'));
61
63
  }
62
64
  for (const declaration of contract.typeDeclarations)
63
- sections.push(declaration.text);
65
+ sections.push(writeDatesAsStrings(declaration.text, 'declaration'));
64
66
  const members = contract.endpoints.map(emitEndpointMember);
65
67
  // A type alias, not an interface: only an object type literal satisfies the Endpoints index signature.
66
68
  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};`);
@@ -86,11 +88,6 @@ export function emitClientIndexModule(controllers, options) {
86
88
  }
87
89
  return `${lines.join('\n')}\n`;
88
90
  }
89
- /** The app file as the client sees it: its exported declarations, verbatim. */
90
- export function emitAppModule(appDeclarations, options) {
91
- const header = [`// Generated by netsuite-api generate from ${options.appLabel}. Do not edit: change ${options.appLabel} and run \`npm run generate\`.`, ESLINT_DISABLE];
92
- return `${[header.join('\n'), ...appDeclarations].join('\n\n')}\n`;
93
- }
94
91
  export function emitScriptsModule(controllers, options) {
95
92
  const ordered = sortControllers(controllers);
96
93
  const entries = ordered.map(({ contract }) => `${INDENT}${contract.name}: ${emitScriptRef(contract.script)},`);
@@ -18,7 +18,7 @@ export interface PlannedFile {
18
18
  content: string;
19
19
  }
20
20
  export interface ClientGenerationPlan {
21
- /** The modules to write: one per controller, the client index, the app module, the scripts map; empty when there are problems. */
21
+ /** The modules to write: one per controller, the client index, the scripts map; empty when there are problems. */
22
22
  files: PlannedFile[];
23
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
24
  leftoverFiles: string[];
@@ -1,9 +1,10 @@
1
1
  import * as nodePath from 'node:path';
2
- import { readAppDeclarations } from './appReader.js';
2
+ import { resolveInlineTypesFile } from './config.js';
3
3
  import { isControllerFileName, readControllerContract } from './controllerReader.js';
4
- import { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitAppModule, emitClientIndexModule, emitControllerModule, emitScriptsModule, sortControllers } from './emit.js';
4
+ import { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitClientIndexModule, emitControllerModule, emitScriptsModule, sortControllers } from './emit.js';
5
5
  import { toPosixPath } from './file-system.js';
6
6
  import { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
7
+ import { readReferencedNames } from './wireTypes.js';
7
8
  const generatedFileNamePattern = /\.gen\.ts$/;
8
9
  function relativeLabel(rootDirectory, filePath) {
9
10
  return toPosixPath(nodePath.relative(rootDirectory, filePath));
@@ -23,6 +24,41 @@ function findDuplicateScriptIds(controllers) {
23
24
  }
24
25
  return problems;
25
26
  }
27
+ /**
28
+ * A Date reached from a request shape, through the controller's own declarations and the copied
29
+ * ones: the handler would receive an ISO string where its annotation promises a Date. (A shape taken
30
+ * from a sibling controller is checked where it is declared, as that controller's own request.)
31
+ */
32
+ function findDatesInRequestShapes(contract, inlinedTypes, controllerLabel) {
33
+ const declarations = new Map();
34
+ for (const declaration of contract.typeDeclarations)
35
+ declarations.set(declaration.name, declaration.text);
36
+ for (const section of inlinedTypes) {
37
+ for (const declaration of section.declarations)
38
+ if (!declarations.has(declaration.name))
39
+ declarations.set(declaration.name, declaration.text);
40
+ }
41
+ const problems = [];
42
+ for (const endpoint of contract.endpoints) {
43
+ if (endpoint.requestType === undefined)
44
+ continue;
45
+ const visited = new Set();
46
+ const pending = readReferencedNames(endpoint.requestType, 'type').map((name) => ({ name, via: endpoint.requestType }));
47
+ while (pending.length > 0) {
48
+ const { name, via } = pending.shift();
49
+ if (name === 'Date') {
50
+ problems.push({ filePath: controllerLabel, message: `endpoint '${endpoint.name}' takes a Date in its request (through ${via}); JSON carries dates as ISO 8601 strings, so take a string and parse it in the handler.` });
51
+ break;
52
+ }
53
+ const text = declarations.get(name);
54
+ if (text === undefined || visited.has(name))
55
+ continue;
56
+ visited.add(name);
57
+ pending.push(...readReferencedNames(text, 'declaration').map((reference) => ({ name: reference, via: name })));
58
+ }
59
+ }
60
+ return problems;
61
+ }
26
62
  /** Every generated file in the output directory that is not one of the given paths. */
27
63
  function findLeftoverFiles(fileSystem, outDirectory, plannedPaths) {
28
64
  const planned = new Set(plannedPaths.map((filePath) => toPosixPath(nodePath.resolve(filePath))));
@@ -34,7 +70,6 @@ export function planClientGeneration({ config, fileSystem }) {
34
70
  const resolve = (relativePath) => nodePath.resolve(config.rootDirectory, relativePath);
35
71
  const label = (absolutePath) => relativeLabel(config.rootDirectory, absolutePath);
36
72
  const controllersDirectory = resolve(config.controllers);
37
- const appFile = resolve(config.appFile);
38
73
  const outDirectory = resolve(config.outDir);
39
74
  const problems = [];
40
75
  const controllerFiles = fileSystem.listFiles(controllersDirectory).filter((filePath) => isControllerFileName(nodePath.basename(filePath)));
@@ -43,15 +78,23 @@ export function planClientGeneration({ config, fileSystem }) {
43
78
  }
44
79
  const controllerNames = new Set(controllerFiles.map((filePath) => nodePath.basename(filePath).replace(/Controller\.ts$/, '')));
45
80
  const inlinableFiles = new Map();
81
+ /** The inlinable file a specifier names, read once; a file that does not exist is reported once and answered as 'missing'. */
46
82
  const readInlinable = (specifier) => {
47
- if (inlinableFiles.has(specifier))
48
- return inlinableFiles.get(specifier);
49
- const filePath = resolve(config.inlineTypes[specifier]);
83
+ const known = inlinableFiles.get(specifier);
84
+ if (known !== undefined)
85
+ return known;
86
+ const relativePath = resolveInlineTypesFile(config.inlineTypes, specifier);
87
+ if (relativePath === undefined)
88
+ return undefined;
89
+ const filePath = resolve(relativePath);
50
90
  let file;
51
91
  if (fileSystem.fileExists(filePath))
52
92
  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.' });
93
+ else {
94
+ file = 'missing';
95
+ const hint = config.inlineTypes[specifier] !== undefined ? 'run the model generator first.' : `'${specifier}' names it.`;
96
+ problems.push({ filePath: label(filePath), message: `the file to copy types from does not exist; ${hint}` });
97
+ }
55
98
  inlinableFiles.set(specifier, file);
56
99
  return file;
57
100
  };
@@ -74,28 +117,22 @@ export function planClientGeneration({ config, fileSystem }) {
74
117
  const inlinedTypes = [];
75
118
  for (const typeImport of contract.inlinedTypeImports) {
76
119
  const file = readInlinable(typeImport.specifier);
77
- if (!file)
120
+ if (!file || file === 'missing')
78
121
  continue;
79
- const selected = selectInlinedTypes(file, typeImport.names, controllerLabel);
122
+ const selected = selectInlinedTypes(file, typeImport.names, controllerLabel, readInlinable);
80
123
  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 });
124
+ for (const selectedSection of selected.sections) {
125
+ const section = inlinedTypes.find((existing) => existing.sourceLabel === selectedSection.filePath);
126
+ if (section)
127
+ section.declarations.push(...selectedSection.declarations.filter((declaration) => !section.declarations.some((existing) => existing.name === declaration.name)));
128
+ else
129
+ inlinedTypes.push({ sourceLabel: selectedSection.filePath, declarations: selectedSection.declarations });
130
+ }
86
131
  }
132
+ problems.push(...findDatesInRequestShapes(contract, inlinedTypes, controllerLabel));
87
133
  emitted.push({ contract, sourceLabel: controllerLabel, inlinedTypes });
88
134
  }
89
135
  problems.push(...findDuplicateScriptIds(emitted));
90
- let appDeclarations = [];
91
- if (fileSystem.fileExists(appFile)) {
92
- const app = readAppDeclarations(label(appFile), fileSystem.readTextFile(appFile));
93
- problems.push(...app.problems);
94
- appDeclarations = app.declarations;
95
- }
96
- else {
97
- problems.push({ filePath: label(appFile), message: 'the app file does not exist.' });
98
- }
99
136
  const controllers = emitted.map(({ contract }) => ({
100
137
  name: contract.name,
101
138
  filePath: contract.filePath,
@@ -112,7 +149,6 @@ export function planClientGeneration({ config, fileSystem }) {
112
149
  content: emitControllerModule(controller, { clientModule: config.clientModule }),
113
150
  })),
114
151
  { path: nodePath.join(outDirectory, CLIENT_INDEX_FILE_NAME), content: emitClientIndexModule(emitted, { controllersLabel }) },
115
- { path: resolve(config.appOutFile), content: emitAppModule(appDeclarations, { appLabel: label(appFile) }) },
116
152
  { path: resolve(config.scriptsOutFile), content: emitScriptsModule(emitted, { wireModule: config.wireModule, controllersLabel }) },
117
153
  ];
118
154
  const leftoverFiles = findLeftoverFiles(fileSystem, outDirectory, files.map((file) => file.path));
@@ -5,10 +5,8 @@ export { GENERATED_CLIENT_NAME, GENERATED_ENDPOINTS_TYPE_NAME, RAW_RESPONSE_TYPE
5
5
  export type { CarriedTypeImport, ControllerContract, ControllerKind, ControllerProblem, ControllerReadResult, ControllerTypeImport, DeclaredScript, EndpointSignature, InlinedTypeImport, ReadControllerOptions, TypeDeclaration, TypeImportName } from './controllerReader.js';
6
6
  export { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
7
7
  export type { InlinableTypeDeclaration, InlinableTypesFile, SelectedInlinedTypes } from './typesFileReader.js';
8
- export { readAppDeclarations } from './appReader.js';
9
- export type { AppDeclarations } from './appReader.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';
8
+ export { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitClientIndexModule, emitControllerModule, emitScriptsModule } from './emit.js';
9
+ export type { EmitClientIndexModuleOptions, EmitControllerModuleOptions, EmitScriptsModuleOptions, EmittedController, InlinedTypeSection } from './emit.js';
12
10
  export { checkClientGeneration, planClientGeneration, runClientGeneration } from './generate.js';
13
11
  export type { ClientGenerationCheck, ClientGenerationPlan, ClientGenerationResult, GenerateClientOptions, PlannedController, PlannedFile } from './generate.js';
14
12
  export { createInMemoryFileSystemAdapter, createNodeFileSystemAdapter, toPosixPath } from './file-system.js';
@@ -2,8 +2,7 @@
2
2
  export { DEFAULT_CONFIG_FILE_NAME, ClientGeneratorConfigError, defaultClientGeneratorConfig, loadClientGeneratorConfig } from './config.js';
3
3
  export { GENERATED_CLIENT_NAME, GENERATED_ENDPOINTS_TYPE_NAME, RAW_RESPONSE_TYPE_NAME, isControllerFileName, readControllerContract, readLeadingJsDoc } from './controllerReader.js';
4
4
  export { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
5
- export { readAppDeclarations } from './appReader.js';
6
- export { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitAppModule, emitClientIndexModule, emitControllerModule, emitScriptsModule } from './emit.js';
5
+ export { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitClientIndexModule, emitControllerModule, emitScriptsModule } from './emit.js';
7
6
  export { checkClientGeneration, planClientGeneration, runClientGeneration } from './generate.js';
8
7
  export { createInMemoryFileSystemAdapter, createNodeFileSystemAdapter, toPosixPath } from './file-system.js';
9
8
  export { CLI_USAGE, EXIT_PROBLEMS, EXIT_SUCCESS, EXIT_USAGE, runCli } from './cli/main.js';
@@ -1,10 +1,12 @@
1
1
  import type { ControllerProblem, TypeDeclaration, TypeImportName } from './controllerReader.js';
2
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.
3
+ * Reads a file whose type declarations the generator copies into a controller's generated module:
4
+ * the generated entity types, or a service (its functions are skipped; its types are what a
5
+ * controller builds wire shapes from). A controller names the types it imports from the file; each
6
+ * of those is copied with every type it refers to, in the same file or, through an import, in
7
+ * another inlinable file, so the generated module stands on its own. A type built on something no
8
+ * inlinable file declares (the repository package's EntityCreate and EntityPatch) cannot be copied,
9
+ * because the client would need that package to resolve it.
8
10
  */
9
11
  export interface InlinableTypeDeclaration extends TypeDeclaration {
10
12
  /** The names of the types the declaration refers to, each once, in the order met. */
@@ -17,15 +19,32 @@ export interface InlinableTypesFile {
17
19
  declarations: Map<string, InlinableTypeDeclaration>;
18
20
  /** Every name the file imports, mapped to the module it comes from. */
19
21
  importedNames: Map<string, string>;
22
+ /** The imports written `Name as Local`: the local name mapped to the name the module exports. */
23
+ renamedImports: Map<string, string>;
20
24
  }
21
- export interface SelectedInlinedTypes {
22
- /** The declarations to copy, in file order, followed by an alias for every renamed import. */
25
+ /** The declarations to copy from one file. */
26
+ export interface SelectedInlinedSection {
27
+ filePath: string;
28
+ /** In file order; the section of the file the controller named ends with an alias for every renamed import. */
23
29
  declarations: TypeDeclaration[];
30
+ }
31
+ export interface SelectedInlinedTypes {
32
+ /** One section per file the selection reached, the file the controller named first. */
33
+ sections: SelectedInlinedSection[];
24
34
  problems: ControllerProblem[];
25
35
  }
36
+ /**
37
+ * Resolves a specifier written in an inlinable file to the inlinable file it names: undefined when
38
+ * the client could not carry it, 'missing' when it names an inlinable file that does not exist (the
39
+ * resolver reports that itself).
40
+ */
41
+ export type ResolveInlinableImport = (specifier: string) => InlinableTypesFile | 'missing' | undefined;
26
42
  export declare function readInlinableTypesFile(filePath: string, source: string): InlinableTypesFile;
27
43
  /**
28
44
  * 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.
45
+ * what they refer to. A reference to a name the file imports is followed into the inlinable file the
46
+ * import resolves to, so a service's summary type built on an entity type carries the entity type
47
+ * with it. A name the file does not declare, or an import the client could not carry, is a problem
48
+ * reported against the controller.
30
49
  */
31
- export declare function selectInlinedTypes(file: InlinableTypesFile, names: TypeImportName[], controllerPath: string): SelectedInlinedTypes;
50
+ export declare function selectInlinedTypes(file: InlinableTypesFile, names: TypeImportName[], controllerPath: string, resolveImport?: ResolveInlinableImport): SelectedInlinedTypes;
@@ -1,21 +1,7 @@
1
1
  import ts from 'typescript';
2
2
  import { readLeadingJsDoc } from './controllerReader.js';
3
- function leftmostIdentifier(name) {
4
- return ts.isIdentifier(name) ? name.text : leftmostIdentifier(name.left);
5
- }
6
- function collectTypeReferences(node, references) {
7
- let referenced;
8
- if (ts.isTypeReferenceNode(node))
9
- referenced = leftmostIdentifier(node.typeName);
10
- else if (ts.isTypeQueryNode(node))
11
- referenced = leftmostIdentifier(node.exprName);
12
- else if (ts.isExpressionWithTypeArguments(node) && ts.isIdentifier(node.expression))
13
- referenced = node.expression.text;
14
- if (referenced !== undefined && !references.includes(referenced))
15
- references.push(referenced);
16
- ts.forEachChild(node, (child) => collectTypeReferences(child, references));
17
- }
18
- function readImportedNames(statement, importedNames) {
3
+ import { collectTypeReferences } from './wireTypes.js';
4
+ function readImportedNames(statement, importedNames, renamedImports) {
19
5
  const clause = statement.importClause;
20
6
  if (!clause || !ts.isStringLiteral(statement.moduleSpecifier))
21
7
  return;
@@ -27,17 +13,22 @@ function readImportedNames(statement, importedNames) {
27
13
  return;
28
14
  if (ts.isNamespaceImport(bindings))
29
15
  importedNames.set(bindings.name.text, moduleSpecifier);
30
- else
31
- for (const element of bindings.elements)
16
+ else {
17
+ for (const element of bindings.elements) {
32
18
  importedNames.set(element.name.text, moduleSpecifier);
19
+ if (element.propertyName)
20
+ renamedImports.set(element.name.text, element.propertyName.text);
21
+ }
22
+ }
33
23
  }
34
24
  export function readInlinableTypesFile(filePath, source) {
35
25
  const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
36
26
  const declarations = new Map();
37
27
  const importedNames = new Map();
28
+ const renamedImports = new Map();
38
29
  for (const statement of sourceFile.statements) {
39
30
  if (ts.isImportDeclaration(statement)) {
40
- readImportedNames(statement, importedNames);
31
+ readImportedNames(statement, importedNames, renamedImports);
41
32
  continue;
42
33
  }
43
34
  if (!ts.isInterfaceDeclaration(statement) && !ts.isTypeAliasDeclaration(statement) && !ts.isEnumDeclaration(statement))
@@ -52,46 +43,87 @@ export function readInlinableTypesFile(filePath, source) {
52
43
  const jsDoc = readLeadingJsDoc(statement, sourceFile);
53
44
  declarations.set(statement.name.text, { name: statement.name.text, text: `${jsDoc ? `${jsDoc}\n` : ''}${statement.getText(sourceFile)}`, references });
54
45
  }
55
- return { filePath, declarations, importedNames };
46
+ return { filePath, declarations, importedNames, renamedImports };
56
47
  }
57
48
  /**
58
49
  * The declarations a controller's imports pull out of the file: the named types and, transitively,
59
- * what they refer to. A name the file does not declare is a problem reported against the controller.
50
+ * what they refer to. A reference to a name the file imports is followed into the inlinable file the
51
+ * import resolves to, so a service's summary type built on an entity type carries the entity type
52
+ * with it. A name the file does not declare, or an import the client could not carry, is a problem
53
+ * reported against the controller.
60
54
  */
61
- export function selectInlinedTypes(file, names, controllerPath) {
55
+ export function selectInlinedTypes(file, names, controllerPath, resolveImport = () => undefined) {
62
56
  const problems = [];
63
- const selected = new Set();
64
- const pending = names.map((imported) => ({ name: imported.name }));
57
+ const reached = new Map([[file.filePath, file]]);
58
+ const selectedByFile = new Map();
59
+ const selectedIn = (current) => {
60
+ let selected = selectedByFile.get(current.filePath);
61
+ if (!selected) {
62
+ selected = new Set();
63
+ selectedByFile.set(current.filePath, selected);
64
+ }
65
+ return selected;
66
+ };
67
+ const pending = names.map((imported) => ({ file, name: imported.name }));
65
68
  while (pending.length > 0) {
66
- const { name, dependent } = pending.shift();
69
+ const { file: current, name, dependent } = pending.shift();
70
+ const selected = selectedIn(current);
67
71
  if (selected.has(name))
68
72
  continue;
69
- const declaration = file.declarations.get(name);
73
+ const declaration = current.declarations.get(name);
70
74
  if (declaration) {
71
75
  selected.add(name);
72
- pending.push(...declaration.references.map((reference) => ({ name: reference, dependent: name })));
76
+ pending.push(...declaration.references.map((reference) => ({ file: current, name: reference, dependent: name })));
73
77
  continue;
74
78
  }
75
- const importedFrom = file.importedNames.get(name);
79
+ const importedFrom = current.importedNames.get(name);
76
80
  if (importedFrom !== undefined) {
77
- problems.push({
78
- filePath: controllerPath,
79
- message: dependent
80
- ? `type '${dependent}' (${file.filePath}) is built on ${name} from '${importedFrom}', which the client cannot carry; write the wire shape out in the controller instead.`
81
- : `type '${name}' is imported into ${file.filePath} from '${importedFrom}', not declared there; the client cannot carry it.`,
82
- });
81
+ const imported = resolveImport(importedFrom);
82
+ const exportedName = current.renamedImports.get(name);
83
+ if (imported === 'missing') {
84
+ // The resolver reported the missing file; the reference has nothing to say beyond that.
85
+ }
86
+ else if (imported && exportedName === undefined) {
87
+ if (!reached.has(imported.filePath))
88
+ reached.set(imported.filePath, imported);
89
+ selected.add(name);
90
+ pending.push({ file: imported, name, dependent });
91
+ }
92
+ else if (imported) {
93
+ problems.push({
94
+ filePath: controllerPath,
95
+ message: dependent
96
+ ? `type '${dependent}' (${current.filePath}) is built on ${name}, imported from '${importedFrom}' as a rename of ${exportedName}; import it under its own name so the client can carry it.`
97
+ : `type '${name}' is imported into ${current.filePath} from '${importedFrom}' as a rename of ${exportedName}; import it under its own name so the client can carry it.`,
98
+ });
99
+ }
100
+ else {
101
+ problems.push({
102
+ filePath: controllerPath,
103
+ message: dependent
104
+ ? `type '${dependent}' (${current.filePath}) is built on ${name} from '${importedFrom}', which the client cannot carry; write the wire shape out in the controller instead.`
105
+ : `type '${name}' is imported into ${current.filePath} from '${importedFrom}', not declared there; the client cannot carry it.`,
106
+ });
107
+ }
83
108
  }
84
109
  else if (dependent === undefined) {
85
- problems.push({ filePath: controllerPath, message: `type '${name}' is not declared in ${file.filePath}.` });
110
+ problems.push({ filePath: controllerPath, message: `type '${name}' is not declared in ${current.filePath}.` });
86
111
  }
87
112
  // Anything else a declaration refers to is a global (Date, Record, Array): nothing to copy.
88
113
  }
89
- const declarations = Array.from(file.declarations.values())
90
- .filter((declaration) => selected.has(declaration.name))
91
- .map(({ name, text }) => ({ name, text }));
114
+ const sections = Array.from(reached.values()).map((current) => {
115
+ const selected = selectedByFile.get(current.filePath);
116
+ return {
117
+ filePath: current.filePath,
118
+ declarations: Array.from(current.declarations.values())
119
+ .filter((declaration) => selected?.has(declaration.name))
120
+ .map(({ name, text }) => ({ name, text })),
121
+ };
122
+ });
123
+ const resolvedNames = new Set(sections.flatMap((section) => section.declarations.map((declaration) => declaration.name)));
92
124
  for (const imported of names) {
93
- if (imported.alias && selected.has(imported.name))
94
- declarations.push({ name: imported.alias, text: `export type ${imported.alias} = ${imported.name};` });
125
+ if (imported.alias && resolvedNames.has(imported.name))
126
+ sections[0].declarations.push({ name: imported.alias, text: `export type ${imported.alias} = ${imported.name};` });
95
127
  }
96
- return { declarations, problems };
128
+ return { sections: sections.filter((section, index) => index === 0 || section.declarations.length > 0), problems };
97
129
  }
@@ -0,0 +1,16 @@
1
+ import ts from 'typescript';
2
+ /**
3
+ * JSON carries no dates: a Date on the wire is an ISO 8601 string in both directions, and neither
4
+ * side revives it. So the generated client module says `string` wherever a wire shape says `Date`,
5
+ * and a request shape may not carry a Date at all, because the handler would receive a string where
6
+ * its annotation promises a Date. The shapes are text (the declarations as written, the handler
7
+ * annotations), so both operations parse the text and work on the syntax tree.
8
+ */
9
+ /** A fragment is a whole declaration (`export interface X { ... }`) or a bare type annotation (`Date[]`). */
10
+ export type WireFragmentKind = 'declaration' | 'type';
11
+ /** Collects, in order met and each once, the names a node refers to: type references, heritage clauses and typeof targets. */
12
+ export declare function collectTypeReferences(node: ts.Node, references: string[]): void;
13
+ /** The names a fragment refers to, `Date` included when it does. */
14
+ export declare function readReferencedNames(text: string, kind: WireFragmentKind): string[];
15
+ /** The fragment with every `Date` type reference written as `string`, everything else byte for byte as it was. */
16
+ export declare function writeDatesAsStrings(text: string, kind: WireFragmentKind): string;
@@ -0,0 +1,50 @@
1
+ import ts from 'typescript';
2
+ const TYPE_FRAGMENT_PREFIX = 'type __WireFragment = ';
3
+ function parseFragment(text, kind) {
4
+ const source = kind === 'type' ? `${TYPE_FRAGMENT_PREFIX}${text};` : text;
5
+ return { sourceFile: ts.createSourceFile('wire.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS), offset: kind === 'type' ? TYPE_FRAGMENT_PREFIX.length : 0 };
6
+ }
7
+ function leftmostIdentifier(name) {
8
+ return ts.isIdentifier(name) ? name.text : leftmostIdentifier(name.left);
9
+ }
10
+ /** Collects, in order met and each once, the names a node refers to: type references, heritage clauses and typeof targets. */
11
+ export function collectTypeReferences(node, references) {
12
+ let referenced;
13
+ if (ts.isTypeReferenceNode(node))
14
+ referenced = leftmostIdentifier(node.typeName);
15
+ else if (ts.isTypeQueryNode(node))
16
+ referenced = leftmostIdentifier(node.exprName);
17
+ else if (ts.isExpressionWithTypeArguments(node) && ts.isIdentifier(node.expression))
18
+ referenced = node.expression.text;
19
+ if (referenced !== undefined && !references.includes(referenced))
20
+ references.push(referenced);
21
+ ts.forEachChild(node, (child) => collectTypeReferences(child, references));
22
+ }
23
+ /** The names a fragment refers to, `Date` included when it does. */
24
+ export function readReferencedNames(text, kind) {
25
+ const { sourceFile } = parseFragment(text, kind);
26
+ const references = [];
27
+ collectTypeReferences(sourceFile, references);
28
+ return references.filter((name) => name !== '__WireFragment');
29
+ }
30
+ function isDateReference(node) {
31
+ return ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && node.typeName.text === 'Date' && node.typeArguments === undefined;
32
+ }
33
+ function collectNodes(node, matches, found) {
34
+ if (matches(node))
35
+ found.push(node);
36
+ ts.forEachChild(node, (child) => collectNodes(child, matches, found));
37
+ }
38
+ /** The fragment with every `Date` type reference written as `string`, everything else byte for byte as it was. */
39
+ export function writeDatesAsStrings(text, kind) {
40
+ const { sourceFile, offset } = parseFragment(text, kind);
41
+ const references = [];
42
+ collectNodes(sourceFile, isDateReference, references);
43
+ let result = text;
44
+ for (const reference of references.sort((left, right) => right.getStart(sourceFile) - left.getStart(sourceFile))) {
45
+ const start = reference.getStart(sourceFile) - offset;
46
+ const end = reference.getEnd() - offset;
47
+ result = `${result.slice(0, start)}string${result.slice(end)}`;
48
+ }
49
+ return result;
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amerilux/netsuite-api",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "The API layer for a NetSuite single-page app: endpoints served by a Restlet or a Suitelet, a typed browser client, SuiteScript module stubs for tests, and a generator that writes the client module from the controllers.",
6
6
  "license": "MIT",
@@ -1,12 +0,0 @@
1
- import type { ControllerProblem } from './controllerReader.js';
2
- /**
3
- * Reads the project's app file (netsuite.ts): the application's names and the ids no controller or
4
- * model owns. The client module carries it verbatim, so the file is plain declarations: exported
5
- * constants and types, no imports, nothing that runs. The generator names anything else.
6
- */
7
- export interface AppDeclarations {
8
- /** Every exported declaration as written, JSDoc included, in file order. */
9
- declarations: string[];
10
- problems: ControllerProblem[];
11
- }
12
- export declare function readAppDeclarations(filePath: string, source: string): AppDeclarations;
@@ -1,31 +0,0 @@
1
- import ts from 'typescript';
2
- import { readLeadingJsDoc } from './controllerReader.js';
3
- function hasExportModifier(node) {
4
- return ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
5
- }
6
- function describeStatement(statement, sourceFile) {
7
- return statement.getText(sourceFile).split('\n')[0].trim();
8
- }
9
- export function readAppDeclarations(filePath, source) {
10
- const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
11
- const declarations = [];
12
- const problems = [];
13
- for (const statement of sourceFile.statements) {
14
- if (ts.isImportDeclaration(statement)) {
15
- problems.push({ filePath, message: `imports nothing: it is copied into the client module verbatim (found ${describeStatement(statement, sourceFile)}).` });
16
- continue;
17
- }
18
- const isDeclaration = ts.isVariableStatement(statement) || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement);
19
- if (!isDeclaration || !hasExportModifier(statement)) {
20
- problems.push({ filePath, message: `holds exported constants and types only, because the client module carries it verbatim (found ${describeStatement(statement, sourceFile)}).` });
21
- continue;
22
- }
23
- if (ts.isVariableStatement(statement) && !(statement.declarationList.flags & ts.NodeFlags.Const)) {
24
- problems.push({ filePath, message: `exports constants only (found ${describeStatement(statement, sourceFile)}).` });
25
- continue;
26
- }
27
- const jsDoc = readLeadingJsDoc(statement, sourceFile);
28
- declarations.push(`${jsDoc ? `${jsDoc}\n` : ''}${statement.getText(sourceFile)}`);
29
- }
30
- return { declarations, problems };
31
- }