@amerilux/netsuite-api 0.2.1 → 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 +9 -7
- package/dist/client/apiClient.js +2 -2
- package/dist/index.d.ts +6 -1
- package/dist/server/endpoint.js +1 -1
- package/dist/server/suiteletClient.js +2 -1
- package/dist-tooling/config.d.ts +12 -4
- package/dist-tooling/config.js +33 -1
- package/dist-tooling/controllerReader.d.ts +2 -2
- package/dist-tooling/controllerReader.js +2 -1
- package/dist-tooling/emit.d.ts +2 -2
- package/dist-tooling/emit.js +7 -5
- package/dist-tooling/generate.js +60 -12
- package/dist-tooling/typesFileReader.d.ts +28 -9
- package/dist-tooling/typesFileReader.js +73 -41
- package/dist-tooling/wireTypes.d.ts +16 -0
- package/dist-tooling/wireTypes.js +50 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
|
@@ -118,11 +120,11 @@ A hook imports `{ customer }` from it, calls `customer.api.search({ search: 'acm
|
|
|
118
120
|
"clientModule": "@amerilux/netsuite-api/client",
|
|
119
121
|
"wireModule": "@amerilux/netsuite-api",
|
|
120
122
|
"typeImports": { "@amerilux/netsuite-api/server": "@amerilux/netsuite-api/client" },
|
|
121
|
-
"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" }
|
|
122
124
|
}
|
|
123
125
|
```
|
|
124
126
|
|
|
125
|
-
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
|
|
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.
|
|
126
128
|
|
|
127
129
|
## The client at runtime
|
|
128
130
|
|
|
@@ -134,7 +136,7 @@ import { configureApiClient } from '@amerilux/netsuite-api/client';
|
|
|
134
136
|
if (import.meta.env.DEV) configureApiClient({ basePaths: { restlet: '/api/restlet', suitelet: '/api/suitelet' } });
|
|
135
137
|
```
|
|
136
138
|
|
|
137
|
-
A failed call rejects with an `ApiClientError` carrying the envelope's status and
|
|
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:
|
|
138
140
|
|
|
139
141
|
```ts
|
|
140
142
|
configureApiClient({ onError: (error, { endpoint }) => showBanner(`${endpoint}: ${error.message}`) });
|
package/dist/client/apiClient.js
CHANGED
|
@@ -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
|
-
/**
|
|
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 {
|
package/dist/server/endpoint.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist-tooling/config.d.ts
CHANGED
|
@@ -22,10 +22,13 @@ export interface ClientGeneratorConfig {
|
|
|
22
22
|
*/
|
|
23
23
|
typeImports: Record<string, string>;
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* generated entity types
|
|
28
|
-
*
|
|
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.
|
|
29
32
|
*/
|
|
30
33
|
inlineTypes: Record<string, string>;
|
|
31
34
|
}
|
|
@@ -35,6 +38,11 @@ export interface ResolvedClientGeneratorConfig extends ClientGeneratorConfig {
|
|
|
35
38
|
}
|
|
36
39
|
export declare const DEFAULT_CONFIG_FILE_NAME = "netsuite-api.config.json";
|
|
37
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;
|
|
38
46
|
export declare class ClientGeneratorConfigError extends Error {
|
|
39
47
|
readonly configPath: string;
|
|
40
48
|
readonly problems: string[];
|
package/dist-tooling/config.js
CHANGED
|
@@ -7,8 +7,31 @@ export const defaultClientGeneratorConfig = {
|
|
|
7
7
|
clientModule: '@amerilux/netsuite-api/client',
|
|
8
8
|
wireModule: '@amerilux/netsuite-api',
|
|
9
9
|
typeImports: { '@amerilux/netsuite-api/server': '@amerilux/netsuite-api/client' },
|
|
10
|
-
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' },
|
|
11
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
|
+
}
|
|
12
35
|
export class ClientGeneratorConfigError extends Error {
|
|
13
36
|
configPath;
|
|
14
37
|
problems;
|
|
@@ -45,6 +68,15 @@ function validateClientGeneratorConfig(raw, configPath) {
|
|
|
45
68
|
else
|
|
46
69
|
problems.push(`'${setting}' must be an object of strings.`);
|
|
47
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
|
+
}
|
|
48
80
|
const known = new Set([...stringSettings, ...mapSettings]);
|
|
49
81
|
for (const key of Object.keys(raw)) {
|
|
50
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,
|
|
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
|
|
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)
|
package/dist-tooling/emit.d.ts
CHANGED
|
@@ -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,
|
|
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
|
*/
|
package/dist-tooling/emit.js
CHANGED
|
@@ -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(`//
|
|
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};`);
|
package/dist-tooling/generate.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import * as nodePath from 'node:path';
|
|
2
|
+
import { resolveInlineTypesFile } from './config.js';
|
|
2
3
|
import { isControllerFileName, readControllerContract } from './controllerReader.js';
|
|
3
4
|
import { CLIENT_INDEX_FILE_NAME, controllerModuleFileName, emitClientIndexModule, emitControllerModule, emitScriptsModule, sortControllers } from './emit.js';
|
|
4
5
|
import { toPosixPath } from './file-system.js';
|
|
5
6
|
import { readInlinableTypesFile, selectInlinedTypes } from './typesFileReader.js';
|
|
7
|
+
import { readReferencedNames } from './wireTypes.js';
|
|
6
8
|
const generatedFileNamePattern = /\.gen\.ts$/;
|
|
7
9
|
function relativeLabel(rootDirectory, filePath) {
|
|
8
10
|
return toPosixPath(nodePath.relative(rootDirectory, filePath));
|
|
@@ -22,6 +24,41 @@ function findDuplicateScriptIds(controllers) {
|
|
|
22
24
|
}
|
|
23
25
|
return problems;
|
|
24
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
|
+
}
|
|
25
62
|
/** Every generated file in the output directory that is not one of the given paths. */
|
|
26
63
|
function findLeftoverFiles(fileSystem, outDirectory, plannedPaths) {
|
|
27
64
|
const planned = new Set(plannedPaths.map((filePath) => toPosixPath(nodePath.resolve(filePath))));
|
|
@@ -41,15 +78,23 @@ export function planClientGeneration({ config, fileSystem }) {
|
|
|
41
78
|
}
|
|
42
79
|
const controllerNames = new Set(controllerFiles.map((filePath) => nodePath.basename(filePath).replace(/Controller\.ts$/, '')));
|
|
43
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'. */
|
|
44
82
|
const readInlinable = (specifier) => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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);
|
|
48
90
|
let file;
|
|
49
91
|
if (fileSystem.fileExists(filePath))
|
|
50
92
|
file = readInlinableTypesFile(label(filePath), fileSystem.readTextFile(filePath));
|
|
51
|
-
else
|
|
52
|
-
|
|
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
|
+
}
|
|
53
98
|
inlinableFiles.set(specifier, file);
|
|
54
99
|
return file;
|
|
55
100
|
};
|
|
@@ -72,16 +117,19 @@ export function planClientGeneration({ config, fileSystem }) {
|
|
|
72
117
|
const inlinedTypes = [];
|
|
73
118
|
for (const typeImport of contract.inlinedTypeImports) {
|
|
74
119
|
const file = readInlinable(typeImport.specifier);
|
|
75
|
-
if (!file)
|
|
120
|
+
if (!file || file === 'missing')
|
|
76
121
|
continue;
|
|
77
|
-
const selected = selectInlinedTypes(file, typeImport.names, controllerLabel);
|
|
122
|
+
const selected = selectInlinedTypes(file, typeImport.names, controllerLabel, readInlinable);
|
|
78
123
|
problems.push(...selected.problems);
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
}
|
|
84
131
|
}
|
|
132
|
+
problems.push(...findDatesInRequestShapes(contract, inlinedTypes, controllerLabel));
|
|
85
133
|
emitted.push({ contract, sourceLabel: controllerLabel, inlinedTypes });
|
|
86
134
|
}
|
|
87
135
|
problems.push(...findDuplicateScriptIds(emitted));
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { ControllerProblem, TypeDeclaration, TypeImportName } from './controllerReader.js';
|
|
2
2
|
/**
|
|
3
|
-
* Reads a
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
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
|
|
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
|
|
64
|
-
const
|
|
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 =
|
|
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 =
|
|
79
|
+
const importedFrom = current.importedNames.get(name);
|
|
76
80
|
if (importedFrom !== undefined) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 ${
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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 &&
|
|
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.
|
|
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",
|