@nage-api/cli 1.0.0-beta.2
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/LICENSE +202 -0
- package/README.md +196 -0
- package/dist/cli.d.ts +25 -0
- package/dist/cli.js +276 -0
- package/dist/commands/create.d.ts +56 -0
- package/dist/commands/create.js +219 -0
- package/dist/commands/doctor.d.ts +47 -0
- package/dist/commands/doctor.js +208 -0
- package/dist/commands/features.d.ts +56 -0
- package/dist/commands/features.js +229 -0
- package/dist/commands/generate.d.ts +37 -0
- package/dist/commands/generate.js +151 -0
- package/dist/fs/file-tree.d.ts +57 -0
- package/dist/fs/file-tree.js +136 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +80 -0
- package/dist/main.d.ts +11 -0
- package/dist/main.js +43 -0
- package/dist/naming.d.ts +36 -0
- package/dist/naming.js +72 -0
- package/dist/templates/app.template.d.ts +19 -0
- package/dist/templates/app.template.js +601 -0
- package/dist/templates/resource.template.d.ts +39 -0
- package/dist/templates/resource.template.js +600 -0
- package/dist/templates/workspace.template.d.ts +22 -0
- package/dist/templates/workspace.template.js +457 -0
- package/dist/workspace/manifest.d.ts +70 -0
- package/dist/workspace/manifest.js +162 -0
- package/dist/workspace/wiring.d.ts +33 -0
- package/dist/workspace/wiring.js +112 -0
- package/package.json +51 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `nage g resource product` (PLAN.md §10.3).
|
|
4
|
+
*
|
|
5
|
+
* One command produces a live, secured, paginated REST resource: entity,
|
|
6
|
+
* service, controller, DTOs, migration and tests, wired into the target app.
|
|
7
|
+
* The point is that it inherits capability rather than copying code — the
|
|
8
|
+
* service extends `ModelService`, so the list DSL, pagination envelope, soft
|
|
9
|
+
* delete and audit fields come from the framework, not from generated
|
|
10
|
+
* boilerplate a developer then has to maintain.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.parseFields = parseFields;
|
|
14
|
+
exports.resourceFiles = resourceFiles;
|
|
15
|
+
exports.compactTimestamp = compactTimestamp;
|
|
16
|
+
const naming_js_1 = require("../naming.js");
|
|
17
|
+
const TYPE_MAP = {
|
|
18
|
+
string: { ts: 'string', validator: '@IsString()', column: 'DataTypes.STRING' },
|
|
19
|
+
text: { ts: 'string', validator: '@IsString()', column: 'DataTypes.TEXT' },
|
|
20
|
+
number: { ts: 'number', validator: '@IsNumber()', column: 'DataTypes.INTEGER' },
|
|
21
|
+
boolean: { ts: 'boolean', validator: '@IsBoolean()', column: 'DataTypes.BOOLEAN' },
|
|
22
|
+
date: { ts: 'string', validator: '@IsDateString()', column: 'DataTypes.DATE' },
|
|
23
|
+
json: { ts: 'Record<string, unknown>', validator: '@IsObject()', column: 'DataTypes.JSON' },
|
|
24
|
+
};
|
|
25
|
+
/** Parse `--fields "name:string,slug:string:unique,note:text:optional"`. */
|
|
26
|
+
function parseFields(raw) {
|
|
27
|
+
if (raw === undefined || raw.trim() === '') {
|
|
28
|
+
// A resource with no declared columns still needs something to hold.
|
|
29
|
+
return [{ name: 'name', type: 'string', unique: false, optional: false }];
|
|
30
|
+
}
|
|
31
|
+
return raw
|
|
32
|
+
.split(',')
|
|
33
|
+
.map((entry) => entry.trim())
|
|
34
|
+
.filter((entry) => entry.length > 0)
|
|
35
|
+
.map((entry) => {
|
|
36
|
+
const [name, type = 'string', ...modifiers] = entry.split(':').map((part) => part.trim());
|
|
37
|
+
if (name === undefined || name === '') {
|
|
38
|
+
throw new Error(`"${entry}" is not a field; use name:type[:modifier]`);
|
|
39
|
+
}
|
|
40
|
+
if (!(type in TYPE_MAP)) {
|
|
41
|
+
throw new Error(`"${type}" is not a supported field type (${Object.keys(TYPE_MAP).join(', ')})`);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
name,
|
|
45
|
+
type: type,
|
|
46
|
+
unique: modifiers.includes('unique'),
|
|
47
|
+
optional: modifiers.includes('optional'),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function resourceFiles(input) {
|
|
52
|
+
const names = (0, naming_js_1.deriveNames)(input.name);
|
|
53
|
+
const base = `apps/${input.appName}/src/modules/${names.kebab}`;
|
|
54
|
+
const route = input.route ?? names.pluralKebab;
|
|
55
|
+
const entityDir = input.entityPackage === undefined
|
|
56
|
+
? `${base}/entities`
|
|
57
|
+
: `packages/${input.entityPackage}/src/entities`;
|
|
58
|
+
const files = [
|
|
59
|
+
{ path: `${entityDir}/${names.kebab}.entity.ts`, contents: entity(names, input.fields) },
|
|
60
|
+
{ path: `${base}/dto/create-${names.kebab}.dto.ts`, contents: createDto(names, input.fields) },
|
|
61
|
+
{ path: `${base}/dto/update-${names.kebab}.dto.ts`, contents: updateDto(names, input.fields) },
|
|
62
|
+
{ path: `${base}/dto/query-${names.kebab}.dto.ts`, contents: queryDto(names, input.fields) },
|
|
63
|
+
{
|
|
64
|
+
path: `${base}/${names.kebab}.service.ts`,
|
|
65
|
+
contents: service(names, input.fields, entityImport(input, names)),
|
|
66
|
+
},
|
|
67
|
+
{ path: `${base}/${names.kebab}.controller.ts`, contents: controller(names, route) },
|
|
68
|
+
{ path: `${base}/${names.kebab}.module.ts`, contents: module_(names) },
|
|
69
|
+
];
|
|
70
|
+
if (input.withSpec !== false) {
|
|
71
|
+
files.push({ path: `${base}/${names.kebab}.service.spec.ts`, contents: serviceSpec(names) }, { path: `${base}/${names.kebab}.e2e-spec.ts`, contents: e2eSpec(names, route, input.fields) });
|
|
72
|
+
}
|
|
73
|
+
if (input.withMigration !== false) {
|
|
74
|
+
const stamp = input.timestamp ?? compactTimestamp();
|
|
75
|
+
files.push({
|
|
76
|
+
path: `database/migrations/${stamp}-create-${names.pluralKebab}.ts`,
|
|
77
|
+
contents: migration(names, input.fields, input.engine, stamp),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return files;
|
|
81
|
+
}
|
|
82
|
+
function entityImport(input, names) {
|
|
83
|
+
return input.entityPackage === undefined
|
|
84
|
+
? `./entities/${names.kebab}.entity.js`
|
|
85
|
+
: `@app/${input.entityPackage}`;
|
|
86
|
+
}
|
|
87
|
+
function entity(names, fields) {
|
|
88
|
+
const columns = fields.map((field) => ` ${field.name}${field.optional ? '?' : ''}: ${TYPE_MAP[field.type].ts};`);
|
|
89
|
+
return [
|
|
90
|
+
"import type { BaseEntity } from '@nage-api/core';",
|
|
91
|
+
'',
|
|
92
|
+
'/**',
|
|
93
|
+
` * ${names.title}.`,
|
|
94
|
+
' *',
|
|
95
|
+
' * `BaseEntity` supplies id, audit columns and soft-delete markers, so they are',
|
|
96
|
+
' * consistent across every model and stamped by the framework rather than by',
|
|
97
|
+
' * hand (PLAN.md §14.2).',
|
|
98
|
+
' */',
|
|
99
|
+
`export interface ${names.pascal} extends BaseEntity {`,
|
|
100
|
+
' readonly id: number;',
|
|
101
|
+
...columns,
|
|
102
|
+
'}',
|
|
103
|
+
'',
|
|
104
|
+
].join('\n');
|
|
105
|
+
}
|
|
106
|
+
function createDto(names, fields) {
|
|
107
|
+
const validators = new Set(fields.map((field) => TYPE_MAP[field.type].validator.slice(1, -2)));
|
|
108
|
+
const properties = fields.flatMap((field) => [
|
|
109
|
+
` ${TYPE_MAP[field.type].validator}`,
|
|
110
|
+
...(field.optional ? [' @IsOptional()'] : []),
|
|
111
|
+
` ${field.name}${field.optional ? '?' : '!'}: ${TYPE_MAP[field.type].ts};`,
|
|
112
|
+
'',
|
|
113
|
+
]);
|
|
114
|
+
// `IsOptional` is only decorated onto an optional field, so importing it
|
|
115
|
+
// unconditionally leaves an unused import behind.
|
|
116
|
+
const imported = fields.some((field) => field.optional)
|
|
117
|
+
? [...validators, 'IsOptional']
|
|
118
|
+
: [...validators];
|
|
119
|
+
return [
|
|
120
|
+
`import { ${imported.sort().join(', ')} } from 'class-validator';`,
|
|
121
|
+
'',
|
|
122
|
+
'/**',
|
|
123
|
+
` * Body accepted when creating a ${names.title.toLowerCase()}.`,
|
|
124
|
+
' *',
|
|
125
|
+
' * Strict by construction: the global pipe rejects unknown properties, so a',
|
|
126
|
+
' * client cannot set audit columns or ids by adding them to the payload.',
|
|
127
|
+
' */',
|
|
128
|
+
`export class Create${names.pascal}Dto {`,
|
|
129
|
+
...properties,
|
|
130
|
+
'}',
|
|
131
|
+
'',
|
|
132
|
+
].join('\n');
|
|
133
|
+
}
|
|
134
|
+
function updateDto(names, fields) {
|
|
135
|
+
const validators = new Set(fields.map((field) => TYPE_MAP[field.type].validator.slice(1, -2)));
|
|
136
|
+
const properties = fields.flatMap((field) => [
|
|
137
|
+
` ${TYPE_MAP[field.type].validator}`,
|
|
138
|
+
' @IsOptional()',
|
|
139
|
+
` ${field.name}?: ${TYPE_MAP[field.type].ts};`,
|
|
140
|
+
'',
|
|
141
|
+
]);
|
|
142
|
+
return [
|
|
143
|
+
`import { ${[...validators, 'IsOptional'].sort().join(', ')} } from 'class-validator';`,
|
|
144
|
+
'',
|
|
145
|
+
'/**',
|
|
146
|
+
' * Every field optional, and every validator restated.',
|
|
147
|
+
' *',
|
|
148
|
+
' * Declared rather than derived: this was originally an empty class merged with',
|
|
149
|
+
' * `Partial<Create…Dto>` at the type level, which reads well but leaves the',
|
|
150
|
+
' * class with no `class-validator` metadata at all. The global pipe runs with',
|
|
151
|
+
' * `whitelist`, so it stripped every property and every PATCH came back 422.',
|
|
152
|
+
' * Decorator metadata is a runtime artefact; a type-level `Partial` cannot',
|
|
153
|
+
' * produce it.',
|
|
154
|
+
' */',
|
|
155
|
+
`export class Update${names.pascal}Dto {`,
|
|
156
|
+
...properties,
|
|
157
|
+
'}',
|
|
158
|
+
'',
|
|
159
|
+
].join('\n');
|
|
160
|
+
}
|
|
161
|
+
function queryDto(names, fields) {
|
|
162
|
+
const filterable = fields.map((field) => `'${field.name}'`).join(', ');
|
|
163
|
+
return [
|
|
164
|
+
"import { defineQueryPolicy } from '@nage-api/data';",
|
|
165
|
+
'',
|
|
166
|
+
`import type { ${names.pascal} } from '../entities/${names.kebab}.entity.js';`,
|
|
167
|
+
'',
|
|
168
|
+
'/**',
|
|
169
|
+
' * What a client may filter, sort and select.',
|
|
170
|
+
' *',
|
|
171
|
+
' * Derived from the entity so the resource is not an open query surface: a',
|
|
172
|
+
' * field absent from these lists cannot be reached from the URL (§12).',
|
|
173
|
+
' */',
|
|
174
|
+
`export const ${names.camel}QueryPolicy = defineQueryPolicy<${names.pascal}>({`,
|
|
175
|
+
` filterable: [${filterable}],`,
|
|
176
|
+
` sortable: [${filterable}, 'created_at'],`,
|
|
177
|
+
` selectable: ['id', ${filterable}, 'created_at', 'updated_at'],`,
|
|
178
|
+
` searchable: [${fields
|
|
179
|
+
.filter((field) => field.type === 'string' || field.type === 'text')
|
|
180
|
+
.map((field) => `'${field.name}'`)
|
|
181
|
+
.join(', ')}],`,
|
|
182
|
+
' maxLimit: 100,',
|
|
183
|
+
'});',
|
|
184
|
+
'',
|
|
185
|
+
].join('\n');
|
|
186
|
+
}
|
|
187
|
+
function service(names, fields, entityPath) {
|
|
188
|
+
const uniqueFields = fields.filter((field) => field.unique);
|
|
189
|
+
// `ConflictError` and `Job` appear only in the uniqueness hook. Importing them
|
|
190
|
+
// unconditionally left an unused import in every resource without a unique
|
|
191
|
+
// field, which `no-unused-vars` fails — so generated code did not pass the lint
|
|
192
|
+
// config generated alongside it.
|
|
193
|
+
const hasUniqueHook = uniqueFields.length > 0;
|
|
194
|
+
return [
|
|
195
|
+
"import { Inject, Injectable } from '@nestjs/common';",
|
|
196
|
+
hasUniqueHook
|
|
197
|
+
? "import { ConflictError, repositoryToken } from '@nage-api/core';"
|
|
198
|
+
: "import { repositoryToken } from '@nage-api/core';",
|
|
199
|
+
"import { ModelService } from '@nage-api/data';",
|
|
200
|
+
hasUniqueHook
|
|
201
|
+
? "import type { Job, RepositoryPort, UnitOfWork } from '@nage-api/core';"
|
|
202
|
+
: "import type { RepositoryPort, UnitOfWork } from '@nage-api/core';",
|
|
203
|
+
"import { NAGE_UNIT_OF_WORK } from '@nage-api/core';",
|
|
204
|
+
'',
|
|
205
|
+
`import type { ${names.pascal} } from '${entityPath}';`,
|
|
206
|
+
'',
|
|
207
|
+
`export const ${names.pascal.toUpperCase()}_REPOSITORY = repositoryToken<${names.pascal}>('${names.pascal}');`,
|
|
208
|
+
'',
|
|
209
|
+
'/**',
|
|
210
|
+
` * ${names.title} service.`,
|
|
211
|
+
' *',
|
|
212
|
+
' * Extends `ModelService`, so the query DSL, pagination, soft delete and audit',
|
|
213
|
+
' * stamping are inherited. Override the lifecycle hooks below to add rules;',
|
|
214
|
+
' * they run inside the transaction.',
|
|
215
|
+
' */',
|
|
216
|
+
'@Injectable()',
|
|
217
|
+
`export class ${names.pascal}Service extends ModelService<${names.pascal}> {`,
|
|
218
|
+
' constructor(',
|
|
219
|
+
` @Inject(${names.pascal.toUpperCase()}_REPOSITORY) repository: RepositoryPort<${names.pascal}>,`,
|
|
220
|
+
' @Inject(NAGE_UNIT_OF_WORK) unitOfWork: UnitOfWork,',
|
|
221
|
+
' ) {',
|
|
222
|
+
' super(repository, { unitOfWork });',
|
|
223
|
+
' }',
|
|
224
|
+
'',
|
|
225
|
+
...(uniqueFields.length > 0
|
|
226
|
+
? [
|
|
227
|
+
' /** Uniqueness is enforced before the write, not by a database error. */',
|
|
228
|
+
` protected override async doBeforeWrite(job: Job<${names.pascal}>): Promise<void> {`,
|
|
229
|
+
...uniqueFields.flatMap((field) => [
|
|
230
|
+
` if (job.body.${field.name} !== undefined) {`,
|
|
231
|
+
` const clash = await this.repository.findOne({ where: { ${field.name}: job.body.${field.name} } });`,
|
|
232
|
+
// `findOne` resolves to `TEntity | null`, never `undefined`, so a
|
|
233
|
+
// `!== undefined` guard here is dead — and the generated lint config
|
|
234
|
+
// fails on it under `no-unnecessary-condition`.
|
|
235
|
+
' if (clash !== null && clash.id !== job.id) {',
|
|
236
|
+
' throw new ConflictError({',
|
|
237
|
+
` message: '${indefiniteArticle(names.title)} ${names.title.toLowerCase()} with that ${field.name} already exists',`,
|
|
238
|
+
` meta: { field: '${field.name}' },`,
|
|
239
|
+
' });',
|
|
240
|
+
' }',
|
|
241
|
+
' }',
|
|
242
|
+
]),
|
|
243
|
+
' }',
|
|
244
|
+
'',
|
|
245
|
+
]
|
|
246
|
+
: []),
|
|
247
|
+
` // Import Job from '@nage-api/core' to use the read hook:`,
|
|
248
|
+
' // protected override doBeforeRead(job: Job<' + names.pascal + '>): void {',
|
|
249
|
+
" // this.scopeToOwner(job, 'user_id'); // restrict to the caller's records",
|
|
250
|
+
' // }',
|
|
251
|
+
'}',
|
|
252
|
+
'',
|
|
253
|
+
].join('\n');
|
|
254
|
+
}
|
|
255
|
+
/** "An invoice", not "A invoice" — the message reaches a client. */
|
|
256
|
+
function indefiniteArticle(noun) {
|
|
257
|
+
return /^[aeiou]/i.test(noun) ? 'An' : 'A';
|
|
258
|
+
}
|
|
259
|
+
function controller(names, route) {
|
|
260
|
+
return [
|
|
261
|
+
'import {',
|
|
262
|
+
' Body,',
|
|
263
|
+
' Controller,',
|
|
264
|
+
' Delete,',
|
|
265
|
+
' Get,',
|
|
266
|
+
' HttpCode,',
|
|
267
|
+
' Param,',
|
|
268
|
+
' ParseIntPipe,',
|
|
269
|
+
' Patch,',
|
|
270
|
+
' Post,',
|
|
271
|
+
' Query,',
|
|
272
|
+
"} from '@nestjs/common';",
|
|
273
|
+
"import { parseQuery } from '@nage-api/data';",
|
|
274
|
+
"import type { Paginated } from '@nage-api/core';",
|
|
275
|
+
'',
|
|
276
|
+
`import { ${names.pascal}Service } from './${names.kebab}.service.js';`,
|
|
277
|
+
`import { Create${names.pascal}Dto } from './dto/create-${names.kebab}.dto.js';`,
|
|
278
|
+
`import { Update${names.pascal}Dto } from './dto/update-${names.kebab}.dto.js';`,
|
|
279
|
+
`import { ${names.camel}QueryPolicy } from './dto/query-${names.kebab}.dto.js';`,
|
|
280
|
+
`import type { ${names.pascal} } from './entities/${names.kebab}.entity.js';`,
|
|
281
|
+
'',
|
|
282
|
+
'/**',
|
|
283
|
+
` * ${names.title} REST resource.`,
|
|
284
|
+
' *',
|
|
285
|
+
' * Handlers return data; the global interceptor wraps it in the envelope and',
|
|
286
|
+
' * the global filter turns any error into one (§16.1). Nothing here touches',
|
|
287
|
+
' * `res`.',
|
|
288
|
+
' */',
|
|
289
|
+
`@Controller('${route}')`,
|
|
290
|
+
`export class ${names.pascal}Controller {`,
|
|
291
|
+
` constructor(private readonly ${names.camel}s: ${names.pascal}Service) {}`,
|
|
292
|
+
'',
|
|
293
|
+
' @Post()',
|
|
294
|
+
` create(@Body() body: Create${names.pascal}Dto): Promise<${names.pascal}> {`,
|
|
295
|
+
` return this.${names.camel}s.create(body);`,
|
|
296
|
+
' }',
|
|
297
|
+
'',
|
|
298
|
+
' @Get()',
|
|
299
|
+
` findAll(@Query() query: Record<string, unknown>): Promise<Paginated<${names.pascal}>> {`,
|
|
300
|
+
' // Untrusted input is validated against the policy before it reaches the driver.',
|
|
301
|
+
` return this.${names.camel}s.findAll(parseQuery(query, ${names.camel}QueryPolicy));`,
|
|
302
|
+
' }',
|
|
303
|
+
'',
|
|
304
|
+
" @Get(':id')",
|
|
305
|
+
` findOne(@Param('id', ParseIntPipe) id: number): Promise<${names.pascal}> {`,
|
|
306
|
+
` return this.${names.camel}s.findByIdOrFail(id);`,
|
|
307
|
+
' }',
|
|
308
|
+
'',
|
|
309
|
+
" @Patch(':id')",
|
|
310
|
+
' update(',
|
|
311
|
+
" @Param('id', ParseIntPipe) id: number,",
|
|
312
|
+
` @Body() body: Update${names.pascal}Dto,`,
|
|
313
|
+
` ): Promise<${names.pascal}> {`,
|
|
314
|
+
` return this.${names.camel}s.update(id, body);`,
|
|
315
|
+
' }',
|
|
316
|
+
'',
|
|
317
|
+
" @Delete(':id')",
|
|
318
|
+
' @HttpCode(204)',
|
|
319
|
+
" remove(@Param('id', ParseIntPipe) id: number): Promise<void> {",
|
|
320
|
+
` return this.${names.camel}s.delete(id);`,
|
|
321
|
+
' }',
|
|
322
|
+
'}',
|
|
323
|
+
'',
|
|
324
|
+
].join('\n');
|
|
325
|
+
}
|
|
326
|
+
function module_(names) {
|
|
327
|
+
return [
|
|
328
|
+
"import { Module } from '@nestjs/common';",
|
|
329
|
+
'',
|
|
330
|
+
`import { ${names.pascal}Controller } from './${names.kebab}.controller.js';`,
|
|
331
|
+
`import { ${names.pascal}Service } from './${names.kebab}.service.js';`,
|
|
332
|
+
'',
|
|
333
|
+
'@Module({',
|
|
334
|
+
` controllers: [${names.pascal}Controller],`,
|
|
335
|
+
` providers: [${names.pascal}Service],`,
|
|
336
|
+
` exports: [${names.pascal}Service],`,
|
|
337
|
+
'})',
|
|
338
|
+
`export class ${names.pascal}Module {}`,
|
|
339
|
+
'',
|
|
340
|
+
].join('\n');
|
|
341
|
+
}
|
|
342
|
+
function serviceSpec(names) {
|
|
343
|
+
return [
|
|
344
|
+
"import { describe, expect, it } from 'vitest';",
|
|
345
|
+
"import { MemoryRepository, MemoryUnitOfWork } from '@nage-api/data';",
|
|
346
|
+
"import { RequestContextService } from '@nage-api/core';",
|
|
347
|
+
'',
|
|
348
|
+
`import { ${names.pascal}Service } from './${names.kebab}.service.js';`,
|
|
349
|
+
`import type { ${names.pascal} } from './entities/${names.kebab}.entity.js';`,
|
|
350
|
+
'',
|
|
351
|
+
'/** The in-memory driver stands in for the database: no container needed. */',
|
|
352
|
+
`describe('${names.pascal}Service', () => {`,
|
|
353
|
+
' const context = new RequestContextService();',
|
|
354
|
+
'',
|
|
355
|
+
' const build = () => {',
|
|
356
|
+
' const unitOfWork = new MemoryUnitOfWork();',
|
|
357
|
+
` const repository = new MemoryRepository<${names.pascal}>('${names.pascal}', { unitOfWork });`,
|
|
358
|
+
` return new ${names.pascal}Service(repository, unitOfWork);`,
|
|
359
|
+
' };',
|
|
360
|
+
'',
|
|
361
|
+
" it('should create and read back a record', async () => {",
|
|
362
|
+
' const service = build();',
|
|
363
|
+
'',
|
|
364
|
+
" const created = await context.run({ requestId: 'req-test-000001', startedAt: 0 }, () =>",
|
|
365
|
+
` service.create({ name: 'First' } as never),`,
|
|
366
|
+
' );',
|
|
367
|
+
'',
|
|
368
|
+
' expect(created.id).toBeDefined();',
|
|
369
|
+
' });',
|
|
370
|
+
'});',
|
|
371
|
+
'',
|
|
372
|
+
].join('\n');
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* A literal body the generated DTO will accept.
|
|
376
|
+
*
|
|
377
|
+
* Built from the field list rather than hardcoded: the DTO validates exactly
|
|
378
|
+
* these fields and the global pipe rejects unknown properties, so a fixed
|
|
379
|
+
* `{ name: 'First' }` payload made the generated spec fail with a 422 for every
|
|
380
|
+
* resource whose fields were not called `name`.
|
|
381
|
+
*/
|
|
382
|
+
function sampleBody(fields, variant) {
|
|
383
|
+
const required = fields.filter((field) => !field.optional);
|
|
384
|
+
const chosen = required.length > 0 ? required : fields;
|
|
385
|
+
const suffix = variant === 'first' ? '' : ' Renamed';
|
|
386
|
+
const members = chosen.map((field) => {
|
|
387
|
+
switch (field.type) {
|
|
388
|
+
case 'number':
|
|
389
|
+
return `${field.name}: ${variant === 'first' ? '1' : '2'}`;
|
|
390
|
+
case 'boolean':
|
|
391
|
+
return `${field.name}: ${variant === 'first' ? 'true' : 'false'}`;
|
|
392
|
+
case 'date':
|
|
393
|
+
return `${field.name}: '2026-01-0${variant === 'first' ? '1' : '2'}T00:00:00.000Z'`;
|
|
394
|
+
case 'json':
|
|
395
|
+
return `${field.name}: { note: 'First${suffix}' }`;
|
|
396
|
+
default:
|
|
397
|
+
return `${field.name}: 'First${suffix}'`;
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
return `{ ${members.join(', ')} }`;
|
|
401
|
+
}
|
|
402
|
+
function e2eSpec(names, route, fields) {
|
|
403
|
+
return [
|
|
404
|
+
"import 'reflect-metadata';",
|
|
405
|
+
'',
|
|
406
|
+
"import { afterAll, beforeAll, describe, expect, it } from 'vitest';",
|
|
407
|
+
"import { Test } from '@nestjs/testing';",
|
|
408
|
+
"import type { INestApplication } from '@nestjs/common';",
|
|
409
|
+
"import { MemoryRepository, MemoryUnitOfWork, NageDataModule, registerModel } from '@nage-api/data';",
|
|
410
|
+
"import { NageCoreModule, enableQueryDsl } from '@nage-api/core';",
|
|
411
|
+
"import request from 'supertest';",
|
|
412
|
+
'',
|
|
413
|
+
`import { ${names.pascal}Module } from './${names.kebab}.module.js';`,
|
|
414
|
+
'',
|
|
415
|
+
'/** The generated CRUD contract: list DSL, pagination, soft delete, envelope. */',
|
|
416
|
+
`describe('${names.title} resource', () => {`,
|
|
417
|
+
' let app: INestApplication;',
|
|
418
|
+
'',
|
|
419
|
+
' beforeAll(async () => {',
|
|
420
|
+
' const unitOfWork = new MemoryUnitOfWork();',
|
|
421
|
+
` const repository = new MemoryRepository('${names.pascal}', { unitOfWork });`,
|
|
422
|
+
'',
|
|
423
|
+
' const moduleRef = await Test.createTestingModule({',
|
|
424
|
+
' imports: [',
|
|
425
|
+
' NageCoreModule.forRoot({',
|
|
426
|
+
` app: { name: 'test', environment: 'test' },`,
|
|
427
|
+
" logging: { level: 'fatal' },",
|
|
428
|
+
' }),',
|
|
429
|
+
' // The same wiring a running app uses, with the in-memory driver',
|
|
430
|
+
' // standing in for the real one. `NageDataModule` is @Global(), which',
|
|
431
|
+
' // is what puts the repository token in reach of the service inside',
|
|
432
|
+
` // ${names.pascal}Module — a provider declared on this testing module`,
|
|
433
|
+
' // would not be, and `overrideProvider` cannot help because the',
|
|
434
|
+
' // module declares no such provider to override.',
|
|
435
|
+
' NageDataModule.forRoot({ unitOfWork }),',
|
|
436
|
+
` NageDataModule.forFeature([registerModel('${names.pascal}', repository)]),`,
|
|
437
|
+
` ${names.pascal}Module,`,
|
|
438
|
+
' ],',
|
|
439
|
+
' }).compile();',
|
|
440
|
+
'',
|
|
441
|
+
' app = moduleRef.createNestApplication();',
|
|
442
|
+
' // `createNestApplication` does not go through `bootstrap`, so the query',
|
|
443
|
+
' // parser has to be enabled here too. Without it Express 5 leaves',
|
|
444
|
+
' // `?where[x]=1` as a flat key and the list DSL silently does nothing.',
|
|
445
|
+
' enableQueryDsl(app);',
|
|
446
|
+
' await app.init();',
|
|
447
|
+
' });',
|
|
448
|
+
'',
|
|
449
|
+
' afterAll(async () => {',
|
|
450
|
+
' await app.close();',
|
|
451
|
+
' });',
|
|
452
|
+
'',
|
|
453
|
+
" it('should create, list, read, update and delete', async () => {",
|
|
454
|
+
' const created = await request(app.getHttpServer())',
|
|
455
|
+
` .post('/${route}')`,
|
|
456
|
+
` .send(${sampleBody(fields, 'first')})`,
|
|
457
|
+
' .expect(201);',
|
|
458
|
+
'',
|
|
459
|
+
' const id = created.body.data.id;',
|
|
460
|
+
'',
|
|
461
|
+
` const list = await request(app.getHttpServer()).get('/${route}').expect(200);`,
|
|
462
|
+
' expect(list.body.data).toHaveLength(1);',
|
|
463
|
+
' expect(list.body.meta.pagination.count).toBe(1);',
|
|
464
|
+
'',
|
|
465
|
+
` await request(app.getHttpServer()).get(\`/${route}/\${id}\`).expect(200);`,
|
|
466
|
+
'',
|
|
467
|
+
' await request(app.getHttpServer())',
|
|
468
|
+
` .patch(\`/${route}/\${id}\`)`,
|
|
469
|
+
` .send(${sampleBody(fields, 'second')})`,
|
|
470
|
+
' .expect(200);',
|
|
471
|
+
'',
|
|
472
|
+
` await request(app.getHttpServer()).delete(\`/${route}/\${id}\`).expect(204);`,
|
|
473
|
+
'',
|
|
474
|
+
' // Soft-deleted records leave the collection.',
|
|
475
|
+
` const after = await request(app.getHttpServer()).get('/${route}').expect(200);`,
|
|
476
|
+
' expect(after.body.data).toHaveLength(0);',
|
|
477
|
+
' });',
|
|
478
|
+
'',
|
|
479
|
+
" it('should reject a query field that is not allow-listed', async () => {",
|
|
480
|
+
' await request(app.getHttpServer())',
|
|
481
|
+
` .get('/${route}?where[secret]=1')`,
|
|
482
|
+
' .expect(400);',
|
|
483
|
+
' });',
|
|
484
|
+
'',
|
|
485
|
+
...(fields.length > 0
|
|
486
|
+
? [
|
|
487
|
+
" it('should filter by an allow-listed field', async () => {",
|
|
488
|
+
' // The positive half: a resource that refused every query would satisfy',
|
|
489
|
+
' // the case above, and the DSL only earns its place if the allowed path',
|
|
490
|
+
' // works. Filters on its own record rather than on whatever an earlier',
|
|
491
|
+
' // test left behind, so the two cases cannot affect each other.',
|
|
492
|
+
` const created = await request(app.getHttpServer())`,
|
|
493
|
+
` .post('/${route}')`,
|
|
494
|
+
` .send(${sampleBody(fields, 'second')})`,
|
|
495
|
+
' .expect(201);',
|
|
496
|
+
'',
|
|
497
|
+
' const filtered = await request(app.getHttpServer())',
|
|
498
|
+
` .get('/${route}?where[${fields[0]?.name ?? 'id'}]=${filterValueFor(fields[0], 'second')}')`,
|
|
499
|
+
' .expect(200);',
|
|
500
|
+
'',
|
|
501
|
+
' expect(filtered.body.data).toHaveLength(1);',
|
|
502
|
+
' expect(filtered.body.data[0].id).toBe(created.body.data.id);',
|
|
503
|
+
' });',
|
|
504
|
+
]
|
|
505
|
+
: []),
|
|
506
|
+
'});',
|
|
507
|
+
'',
|
|
508
|
+
].join('\n');
|
|
509
|
+
}
|
|
510
|
+
/** The URL-encoded value that matches `sampleBody(fields, variant)` for a field. */
|
|
511
|
+
function filterValueFor(field, variant) {
|
|
512
|
+
const second = variant === 'second';
|
|
513
|
+
if (field === undefined)
|
|
514
|
+
return second ? '2' : '1';
|
|
515
|
+
switch (field.type) {
|
|
516
|
+
case 'number':
|
|
517
|
+
return second ? '2' : '1';
|
|
518
|
+
case 'boolean':
|
|
519
|
+
return second ? 'false' : 'true';
|
|
520
|
+
case 'date':
|
|
521
|
+
return `2026-01-0${second ? '2' : '1'}T00%3A00%3A00.000Z`;
|
|
522
|
+
default:
|
|
523
|
+
// The sample body's second variant is "First Renamed"; a raw space in a
|
|
524
|
+
// query string is not a value the server will see intact.
|
|
525
|
+
return second ? 'First%20Renamed' : 'First';
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function migration(names, fields, engine, stamp) {
|
|
529
|
+
if (engine === 'mongodb') {
|
|
530
|
+
return [
|
|
531
|
+
"import type { Migration } from '@nage-api/data';",
|
|
532
|
+
'',
|
|
533
|
+
'/** Collections are schemaless; this creates the indexes the resource needs. */',
|
|
534
|
+
'const migration: Migration = {',
|
|
535
|
+
` id: '${stamp}',`,
|
|
536
|
+
` name: 'create-${names.pluralKebab}',`,
|
|
537
|
+
' up: async (context) => {',
|
|
538
|
+
` // await context.createCollection('${names.pluralSnake}');`,
|
|
539
|
+
...fields
|
|
540
|
+
.filter((field) => field.unique)
|
|
541
|
+
.map((field) => ` // await context.createIndex('${names.pluralSnake}', { ${field.name}: 1 }, { unique: true });`),
|
|
542
|
+
' },',
|
|
543
|
+
' down: async (context) => {',
|
|
544
|
+
` // await context.dropCollection('${names.pluralSnake}');`,
|
|
545
|
+
' },',
|
|
546
|
+
'};',
|
|
547
|
+
'',
|
|
548
|
+
'export default migration;',
|
|
549
|
+
'',
|
|
550
|
+
].join('\n');
|
|
551
|
+
}
|
|
552
|
+
const columns = fields.map((field) => {
|
|
553
|
+
const parts = [`type: ${TYPE_MAP[field.type].column}`, `allowNull: ${String(field.optional)}`];
|
|
554
|
+
if (field.unique)
|
|
555
|
+
parts.push('unique: true');
|
|
556
|
+
return ` ${field.name}: { ${parts.join(', ')} },`;
|
|
557
|
+
});
|
|
558
|
+
return [
|
|
559
|
+
"import { DataTypes } from 'sequelize';",
|
|
560
|
+
"import type { Migration } from '@nage-api/data';",
|
|
561
|
+
"import type { Sequelize } from 'sequelize';",
|
|
562
|
+
'',
|
|
563
|
+
'/**',
|
|
564
|
+
` * Creates the ${names.pluralSnake} table.`,
|
|
565
|
+
' *',
|
|
566
|
+
' * Reviewed and applied with `nage db migrate` — never `synchronize` (§14.2).',
|
|
567
|
+
' */',
|
|
568
|
+
'const migration: Migration<Sequelize> = {',
|
|
569
|
+
` id: '${stamp}',`,
|
|
570
|
+
` name: 'create-${names.pluralKebab}',`,
|
|
571
|
+
' up: async (sequelize) => {',
|
|
572
|
+
` await sequelize.getQueryInterface().createTable('${names.pluralSnake}', {`,
|
|
573
|
+
' id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },',
|
|
574
|
+
...columns,
|
|
575
|
+
' created_at: { type: DataTypes.STRING, allowNull: false },',
|
|
576
|
+
' updated_at: { type: DataTypes.STRING, allowNull: false },',
|
|
577
|
+
' created_by: { type: DataTypes.INTEGER, allowNull: true },',
|
|
578
|
+
' updated_by: { type: DataTypes.INTEGER, allowNull: true },',
|
|
579
|
+
' deleted_at: { type: DataTypes.STRING, allowNull: true },',
|
|
580
|
+
' deleted_by: { type: DataTypes.INTEGER, allowNull: true },',
|
|
581
|
+
' });',
|
|
582
|
+
' },',
|
|
583
|
+
' down: async (sequelize) => {',
|
|
584
|
+
` await sequelize.getQueryInterface().dropTable('${names.pluralSnake}');`,
|
|
585
|
+
' },',
|
|
586
|
+
'};',
|
|
587
|
+
'',
|
|
588
|
+
'export default migration;',
|
|
589
|
+
'',
|
|
590
|
+
].join('\n');
|
|
591
|
+
}
|
|
592
|
+
/** `20260814T134512` — sorts chronologically and reads as a date. */
|
|
593
|
+
function compactTimestamp(now = new Date()) {
|
|
594
|
+
return now
|
|
595
|
+
.toISOString()
|
|
596
|
+
.replace(/[-:]/g, '')
|
|
597
|
+
.replace(/\.\d+Z$/, '')
|
|
598
|
+
.replace('T', 'T');
|
|
599
|
+
}
|
|
600
|
+
//# sourceMappingURL=resource.template.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-level files emitted by `nage create` (PLAN.md §9.1).
|
|
3
|
+
*
|
|
4
|
+
* The generated workspace is monorepo-first: one lockfile, one CI pipeline, one
|
|
5
|
+
* set of tooling, seeded with a single app so the simple case stays simple.
|
|
6
|
+
* Everything here is production-shaped from the first commit — strict TypeScript,
|
|
7
|
+
* a CI workflow, Docker, and a `.env.example` documenting every variable — because
|
|
8
|
+
* retrofitting those onto a project that started loose rarely happens.
|
|
9
|
+
*/
|
|
10
|
+
import type { DatabaseDriver } from '@nage-api/contracts';
|
|
11
|
+
import type { PlannedFile } from '../fs/file-tree.js';
|
|
12
|
+
import { type WorkspaceManifest } from '../workspace/manifest.js';
|
|
13
|
+
export interface WorkspaceTemplateInput {
|
|
14
|
+
readonly manifest: WorkspaceManifest;
|
|
15
|
+
readonly packageManager: 'pnpm' | 'npm' | 'yarn';
|
|
16
|
+
}
|
|
17
|
+
/** The driver package that matches the workspace engine (§14.1: one, never both). */
|
|
18
|
+
export declare function driverPackageFor(engine: DatabaseDriver): string;
|
|
19
|
+
export declare function workspaceFiles(input: WorkspaceTemplateInput): PlannedFile[];
|
|
20
|
+
/** Deterministic JSON with a trailing newline, so regeneration is diff-free. */
|
|
21
|
+
export declare function json(value: unknown): string;
|
|
22
|
+
//# sourceMappingURL=workspace.template.d.ts.map
|