@veloxts/cli 0.6.97 → 0.6.98

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @veloxts/cli
2
2
 
3
+ ## 0.6.98
4
+
5
+ ### Patch Changes
6
+
7
+ - feat(router): nested resource schema relations with .hasOne() / .hasMany()
8
+ - Updated dependencies
9
+ - @veloxts/auth@0.6.98
10
+ - @veloxts/core@0.6.98
11
+ - @veloxts/orm@0.6.98
12
+ - @veloxts/router@0.6.98
13
+ - @veloxts/validation@0.6.98
14
+
3
15
  ## 0.6.97
4
16
 
5
17
  ### Patch Changes
@@ -13,6 +13,8 @@
13
13
  */
14
14
  import { BaseGenerator } from '../base.js';
15
15
  import { getNamespaceInstructions, getNamespaceProcedurePath, getNamespaceSchemaPath, getNamespaceTestPath, namespaceSchemaTemplate, namespaceTemplate, namespaceTestTemplate, } from '../templates/namespace.js';
16
+ import { deriveEntityNames } from '../utils/naming.js';
17
+ import { analyzePrismaSchema, findPrismaSchema, getModelRelations, hasModel, } from '../utils/prisma-schema.js';
16
18
  import { detectRouterPattern, isProcedureRegistered, registerProcedures, } from '../utils/router-integration.js';
17
19
  // ============================================================================
18
20
  // Generator Implementation
@@ -96,9 +98,31 @@ Examples:
96
98
  * Generate namespace files
97
99
  */
98
100
  async generate(config) {
99
- const context = this.createContext(config);
101
+ // Detect relations from existing Prisma model before creating context
102
+ const enrichedOptions = { ...config.options };
103
+ const schemaPath = findPrismaSchema(config.cwd);
104
+ if (schemaPath) {
105
+ try {
106
+ const entity = deriveEntityNames(config.entityName);
107
+ const schemaAnalysis = analyzePrismaSchema(schemaPath);
108
+ if (hasModel(schemaAnalysis, entity.pascal)) {
109
+ const modelRelations = getModelRelations(schemaAnalysis, entity.pascal);
110
+ if (modelRelations.hasOne.length > 0 || modelRelations.hasMany.length > 0) {
111
+ enrichedOptions.relations = {
112
+ hasOne: [...modelRelations.hasOne],
113
+ hasMany: [...modelRelations.hasMany],
114
+ };
115
+ }
116
+ }
117
+ }
118
+ catch {
119
+ // Schema parsing failed — proceed without relations
120
+ }
121
+ }
122
+ const enrichedConfig = { ...config, options: enrichedOptions };
123
+ const context = this.createContext(enrichedConfig);
100
124
  const { entity } = context;
101
- const { options } = config;
125
+ const { options } = enrichedConfig;
102
126
  // Collect files to generate
103
127
  const files = [];
104
128
  // Generate schema file
@@ -19,7 +19,7 @@ import { generateInjectablePrismaContent, generateResourceFiles, } from '../temp
19
19
  import { findAllSimilarFiles, formatSimilarFilesWarning } from '../utils/filesystem.js';
20
20
  import { deriveEntityNames } from '../utils/naming.js';
21
21
  import { promptAndRunMigration } from '../utils/prisma-migration.js';
22
- import { analyzePrismaSchema, findPrismaSchema, hasModel, injectIntoSchema, } from '../utils/prisma-schema.js';
22
+ import { analyzePrismaSchema, findPrismaSchema, getModelRelations, hasModel, injectIntoSchema, } from '../utils/prisma-schema.js';
23
23
  import { detectRouterPattern, isProcedureRegistered, registerProcedures, } from '../utils/router-integration.js';
24
24
  import { createSnapshot, rollback, saveOriginal, trackCreated, } from '../utils/snapshot.js';
25
25
  // ============================================================================
@@ -257,6 +257,26 @@ export class ResourceGenerator extends BaseGenerator {
257
257
  }
258
258
  }
259
259
  }
260
+ // Detect relations from existing Prisma schema (if model exists)
261
+ let relations;
262
+ const schemaPath = findPrismaSchema(config.cwd);
263
+ if (schemaPath) {
264
+ try {
265
+ const schemaAnalysis = analyzePrismaSchema(schemaPath);
266
+ if (hasModel(schemaAnalysis, ctx.entity.pascal)) {
267
+ const modelRelations = getModelRelations(schemaAnalysis, ctx.entity.pascal);
268
+ if (modelRelations.hasOne.length > 0 || modelRelations.hasMany.length > 0) {
269
+ relations = {
270
+ hasOne: [...modelRelations.hasOne],
271
+ hasMany: [...modelRelations.hasMany],
272
+ };
273
+ }
274
+ }
275
+ }
276
+ catch {
277
+ // Schema parsing failed — proceed without relations
278
+ }
279
+ }
260
280
  // Show spinner during actual file generation
261
281
  // Only show if we're in interactive mode (we collected fields or user opted to skip)
262
282
  const showSpinner = interactive && !skipFields && !config.dryRun;
@@ -265,7 +285,7 @@ export class ResourceGenerator extends BaseGenerator {
265
285
  const s = p.spinner();
266
286
  s.start('Scaffolding resource...');
267
287
  try {
268
- generatedFiles = generateResourceFiles(ctx);
288
+ generatedFiles = generateResourceFiles(ctx, relations);
269
289
  s.stop(`Scaffolded ${generatedFiles.length} file(s)`);
270
290
  }
271
291
  catch (err) {
@@ -274,7 +294,7 @@ export class ResourceGenerator extends BaseGenerator {
274
294
  }
275
295
  }
276
296
  else {
277
- generatedFiles = generateResourceFiles(ctx);
297
+ generatedFiles = generateResourceFiles(ctx, relations);
278
298
  }
279
299
  // Auto-registration (skip in dry-run mode)
280
300
  let autoResult = {
@@ -6,6 +6,15 @@
6
6
  * custom procedures rather than pre-defined CRUD operations.
7
7
  */
8
8
  import type { TemplateContext, TemplateFunction } from '../types.js';
9
+ /**
10
+ * Relation info for code generation (shared with resource template)
11
+ */
12
+ export interface RelationInfo {
13
+ /** Single-object relation field names */
14
+ hasOne: string[];
15
+ /** Array relation field names */
16
+ hasMany: string[];
17
+ }
9
18
  export interface NamespaceOptions {
10
19
  /** Skip auto-registering in router.ts */
11
20
  skipRegistration: boolean;
@@ -13,6 +22,8 @@ export interface NamespaceOptions {
13
22
  withExample: boolean;
14
23
  /** Generate test file */
15
24
  withTests: boolean;
25
+ /** Detected relations from Prisma schema */
26
+ relations?: RelationInfo;
16
27
  }
17
28
  /**
18
29
  * Generate namespace procedure file
@@ -6,6 +6,21 @@
6
6
  * custom procedures rather than pre-defined CRUD operations.
7
7
  */
8
8
  // ============================================================================
9
+ // Relation Helpers
10
+ // ============================================================================
11
+ /**
12
+ * Build an `include: { ... }` clause string for Prisma queries
13
+ */
14
+ function buildIncludeClause(relations) {
15
+ if (!relations)
16
+ return '';
17
+ const allRelations = [...relations.hasOne, ...relations.hasMany];
18
+ if (allRelations.length === 0)
19
+ return '';
20
+ const entries = allRelations.map((name) => `${name}: true`).join(', ');
21
+ return `\n include: { ${entries} },`;
22
+ }
23
+ // ============================================================================
9
24
  // Template Functions
10
25
  // ============================================================================
11
26
  /**
@@ -64,7 +79,8 @@ export const ${entity.camel}Procedures = procedures('${entity.plural}', {
64
79
  * Generate namespace with example procedure
65
80
  */
66
81
  function generateWithExample(ctx) {
67
- const { entity } = ctx;
82
+ const { entity, options } = ctx;
83
+ const includeClause = buildIncludeClause(options.relations);
68
84
  return `/**
69
85
  * ${entity.pascal} Procedures
70
86
  *
@@ -92,7 +108,7 @@ export const ${entity.camel}Procedures = procedures('${entity.plural}', {
92
108
  .output(${entity.pascal}Schema.nullable())
93
109
  .query(async ({ input, ctx }) => {
94
110
  return ctx.db.${entity.camel}.findUnique({
95
- where: { id: input.id },
111
+ where: { id: input.id },${includeClause}
96
112
  });
97
113
  }),
98
114
 
@@ -103,7 +119,7 @@ export const ${entity.camel}Procedures = procedures('${entity.plural}', {
103
119
  list${entity.pascalPlural}: procedure()
104
120
  .output(z.array(${entity.pascal}Schema))
105
121
  .query(async ({ ctx }) => {
106
- return ctx.db.${entity.camel}.findMany();
122
+ return ctx.db.${entity.camel}.findMany(${includeClause ? `{${includeClause}\n }` : ''});
107
123
  }),
108
124
 
109
125
  /**
@@ -88,10 +88,19 @@ export declare function generateInjectablePrismaContent(entity: {
88
88
  pascal: string;
89
89
  camel: string;
90
90
  }, options: ResourceOptions, database?: DatabaseType): InjectablePrismaContent;
91
+ /**
92
+ * Relation info for code generation
93
+ */
94
+ export interface RelationInfo {
95
+ /** Single-object relation field names */
96
+ hasOne: string[];
97
+ /** Array relation field names */
98
+ hasMany: string[];
99
+ }
91
100
  /**
92
101
  * Generate all files for a resource
93
102
  */
94
- export declare function generateResourceFiles(ctx: TemplateContext<ResourceOptions>): GeneratedFile[];
103
+ export declare function generateResourceFiles(ctx: TemplateContext<ResourceOptions>, relations?: RelationInfo): GeneratedFile[];
95
104
  /**
96
105
  * Generate post-generation instructions
97
106
  */
@@ -123,6 +123,23 @@ export function generatePrismaEnums(fields) {
123
123
  });
124
124
  return `\n${enums.join('\n\n')}\n`;
125
125
  }
126
+ // ============================================================================
127
+ // Relation Helpers
128
+ // ============================================================================
129
+ /**
130
+ * Build an `include: { ... }` clause string for Prisma queries
131
+ *
132
+ * Returns empty string if no relations, or a formatted include block
133
+ */
134
+ function buildIncludeClause(relations) {
135
+ if (!relations)
136
+ return '';
137
+ const allRelations = [...relations.hasOne, ...relations.hasMany];
138
+ if (allRelations.length === 0)
139
+ return '';
140
+ const entries = allRelations.map((name) => `${name}: true`).join(', ');
141
+ return `\n include: { ${entries} },`;
142
+ }
126
143
  /**
127
144
  * Generate injectable Prisma content (model + enums without comments)
128
145
  *
@@ -334,12 +351,11 @@ export type ${pascal} = z.infer<typeof ${camel}Schema>;
334
351
  ${crudSchemas}
335
352
  `;
336
353
  }
337
- // ============================================================================
338
- // Procedure Template
339
- // ============================================================================
340
- function generateProcedure(entity, options) {
354
+ function generateProcedure(entity, options, relations) {
341
355
  const { pascal, camel, kebab } = entity;
342
356
  const { crud, paginated, softDelete } = options;
357
+ // Build include clause if relations exist
358
+ const includeClause = buildIncludeClause(relations);
343
359
  if (!crud) {
344
360
  // Simple procedure without CRUD
345
361
  return `/**
@@ -361,7 +377,7 @@ export const ${camel}Procedures = procedures('${kebab}s', {
361
377
  .output(${camel}Schema.nullable())
362
378
  .query(async ({ input, ctx }) => {
363
379
  return ctx.db.${camel}.findUnique({
364
- where: { id: input.id },
380
+ where: { id: input.id },${includeClause}
365
381
  });
366
382
  }),
367
383
  });
@@ -421,7 +437,7 @@ export const ${camel}Procedures = procedures('${kebab}s', {
421
437
  .output(${camel}Schema.nullable())
422
438
  .query(async ({ input, ctx }) => {
423
439
  return ctx.db.${camel}.findUnique({
424
- where: { id: input.id${softDeleteWhere} },
440
+ where: { id: input.id${softDeleteWhere} },${includeClause}
425
441
  });
426
442
  }),
427
443
 
@@ -439,7 +455,7 @@ export const ${camel}Procedures = procedures('${kebab}s', {
439
455
  take: input.limit,
440
456
  orderBy: input.sortBy
441
457
  ? { [input.sortBy]: input.sortOrder }
442
- : { createdAt: 'desc' },
458
+ : { createdAt: 'desc' },${includeClause}
443
459
  });
444
460
  ${paginationCode}
445
461
  }),
@@ -626,7 +642,7 @@ describe('${pascal} Procedures', () => {
626
642
  /**
627
643
  * Generate all files for a resource
628
644
  */
629
- export function generateResourceFiles(ctx) {
645
+ export function generateResourceFiles(ctx, relations) {
630
646
  const files = [];
631
647
  const { entity, options, project } = ctx;
632
648
  // Prisma model (added to models folder for reference)
@@ -647,7 +663,7 @@ export function generateResourceFiles(ctx) {
647
663
  if (!options.skipProcedure) {
648
664
  files.push({
649
665
  path: `src/procedures/${entity.kebab}.ts`,
650
- content: generateProcedure(entity, options),
666
+ content: generateProcedure(entity, options, relations),
651
667
  });
652
668
  }
653
669
  // Tests
@@ -4,6 +4,41 @@
4
4
  * Parses Prisma schema files to find models/enums and enables safe injection
5
5
  * of new definitions without breaking existing code.
6
6
  */
7
+ /**
8
+ * Parsed information about a single field in a Prisma model
9
+ */
10
+ export interface PrismaFieldInfo {
11
+ /** Field name (e.g., 'author', 'posts') */
12
+ readonly name: string;
13
+ /** Base type without modifiers (e.g., 'User', 'Post') */
14
+ readonly type: string;
15
+ /** Whether this field is a relation to another model */
16
+ readonly isRelation: boolean;
17
+ /** Whether this is an array relation (e.g., Post[]) */
18
+ readonly isArray: boolean;
19
+ /** The related model name, if this is a relation field */
20
+ readonly relatedModel: string | undefined;
21
+ }
22
+ /**
23
+ * Detailed information about a Prisma model including its fields
24
+ */
25
+ export interface PrismaModelInfo {
26
+ /** Model name (PascalCase) */
27
+ readonly name: string;
28
+ /** All parsed fields */
29
+ readonly fields: readonly PrismaFieldInfo[];
30
+ /** Only the relation fields (convenience accessor) */
31
+ readonly relationFields: readonly PrismaFieldInfo[];
32
+ }
33
+ /**
34
+ * Relation info categorized for code generation
35
+ */
36
+ export interface ModelRelations {
37
+ /** Single-object relations (e.g., author User) */
38
+ readonly hasOne: readonly string[];
39
+ /** Array relations (e.g., posts Post[]) */
40
+ readonly hasMany: readonly string[];
41
+ }
7
42
  /**
8
43
  * Represents a parsed Prisma schema
9
44
  */
@@ -22,6 +57,8 @@ export interface PrismaSchemaAnalysis {
22
57
  readonly lastEnumEnd: number;
23
58
  /** Position where models start (after datasource/generator blocks) */
24
59
  readonly modelSectionStart: number;
60
+ /** Detailed model info with field-level data */
61
+ readonly modelDetails: Map<string, PrismaModelInfo>;
25
62
  }
26
63
  /**
27
64
  * Model definition to inject
@@ -64,6 +101,12 @@ export declare function findPrismaSchema(projectRoot: string): string | null;
64
101
  * Parse a Prisma schema file
65
102
  */
66
103
  export declare function analyzePrismaSchema(filePath: string): PrismaSchemaAnalysis;
104
+ /**
105
+ * Get categorized relations for a model
106
+ *
107
+ * Returns hasOne (single-object) and hasMany (array) relation field names
108
+ */
109
+ export declare function getModelRelations(analysis: PrismaSchemaAnalysis, modelName: string): ModelRelations;
67
110
  /**
68
111
  * Check if a model exists in the schema
69
112
  */
@@ -65,6 +65,8 @@ export function analyzePrismaSchema(filePath) {
65
65
  if (modelSectionStart === 0) {
66
66
  modelSectionStart = findEndOfConfigBlocks(content);
67
67
  }
68
+ // Parse field-level details for each model
69
+ const modelDetails = parseModelDetails(content, models);
68
70
  return {
69
71
  content,
70
72
  filePath,
@@ -73,6 +75,7 @@ export function analyzePrismaSchema(filePath) {
73
75
  lastModelEnd,
74
76
  lastEnumEnd,
75
77
  modelSectionStart,
78
+ modelDetails,
76
79
  };
77
80
  }
78
81
  /**
@@ -115,6 +118,103 @@ function findEndOfConfigBlocks(content) {
115
118
  return lastEnd;
116
119
  }
117
120
  // ============================================================================
121
+ // Field-Level Parsing
122
+ // ============================================================================
123
+ /**
124
+ * Parse detailed field information for all models in the schema
125
+ */
126
+ function parseModelDetails(content, modelNames) {
127
+ const details = new Map();
128
+ const modelRegex = /^model\s+(\w+)\s*\{/gm;
129
+ for (const match of content.matchAll(modelRegex)) {
130
+ const modelName = match[1];
131
+ const blockStart = content.indexOf('{', match.index ?? 0);
132
+ const blockEnd = findBlockEnd(content, match.index ?? 0);
133
+ const body = content.slice(blockStart + 1, blockEnd - 1);
134
+ const fields = parseModelFields(body, modelNames);
135
+ details.set(modelName, {
136
+ name: modelName,
137
+ fields,
138
+ relationFields: fields.filter((f) => f.isRelation),
139
+ });
140
+ }
141
+ return details;
142
+ }
143
+ /**
144
+ * Parse individual fields from a model body string
145
+ *
146
+ * Detects relation fields by checking if the field type matches a known model name.
147
+ * Skips back-reference fields (those with @relation(...) that are the inverse side).
148
+ */
149
+ function parseModelFields(body, modelNames) {
150
+ const fields = [];
151
+ const lines = body.split('\n');
152
+ for (const line of lines) {
153
+ const trimmed = line.trim();
154
+ // Skip empty lines, comments, and directives (@@map, @@unique, etc.)
155
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('@@')) {
156
+ continue;
157
+ }
158
+ // Parse field: name Type[?][] [@annotations...]
159
+ // Match: fieldName TypeName optional modifiers
160
+ const fieldMatch = trimmed.match(/^(\w+)\s+(\w+)(\[\])?\??/);
161
+ if (!fieldMatch)
162
+ continue;
163
+ const name = fieldMatch[1];
164
+ const baseType = fieldMatch[2];
165
+ const isArray = fieldMatch[3] === '[]';
166
+ // Check if this is a relation field (type matches a known model)
167
+ const isRelation = modelNames.has(baseType);
168
+ if (!isRelation) {
169
+ fields.push({
170
+ name,
171
+ type: baseType,
172
+ isRelation: false,
173
+ isArray: false,
174
+ relatedModel: undefined,
175
+ });
176
+ continue;
177
+ }
178
+ // For relation fields, skip back-references (inverse side with @relation)
179
+ // Back-references typically have @relation and reference back to the parent
180
+ // We detect them by the presence of `fields:` and `references:` in @relation
181
+ const hasRelationWithForeignKey = /@relation\([^)]*fields:\s*\[/.test(trimmed);
182
+ if (hasRelationWithForeignKey) {
183
+ continue;
184
+ }
185
+ fields.push({
186
+ name,
187
+ type: baseType,
188
+ isRelation: true,
189
+ isArray,
190
+ relatedModel: baseType,
191
+ });
192
+ }
193
+ return fields;
194
+ }
195
+ /**
196
+ * Get categorized relations for a model
197
+ *
198
+ * Returns hasOne (single-object) and hasMany (array) relation field names
199
+ */
200
+ export function getModelRelations(analysis, modelName) {
201
+ const modelInfo = analysis.modelDetails.get(modelName);
202
+ if (!modelInfo) {
203
+ return { hasOne: [], hasMany: [] };
204
+ }
205
+ const hasOne = [];
206
+ const hasMany = [];
207
+ for (const field of modelInfo.relationFields) {
208
+ if (field.isArray) {
209
+ hasMany.push(field.name);
210
+ }
211
+ else {
212
+ hasOne.push(field.name);
213
+ }
214
+ }
215
+ return { hasOne, hasMany };
216
+ }
217
+ // ============================================================================
118
218
  // Schema Modification
119
219
  // ============================================================================
120
220
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veloxts/cli",
3
- "version": "0.6.97",
3
+ "version": "0.6.98",
4
4
  "description": "Developer tooling and CLI commands for VeloxTS framework",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -41,11 +41,11 @@
41
41
  "pluralize": "8.0.0",
42
42
  "tsx": "4.21.0",
43
43
  "yaml": "2.8.2",
44
- "@veloxts/auth": "0.6.97",
45
- "@veloxts/orm": "0.6.97",
46
- "@veloxts/core": "0.6.97",
47
- "@veloxts/validation": "0.6.97",
48
- "@veloxts/router": "0.6.97"
44
+ "@veloxts/auth": "0.6.98",
45
+ "@veloxts/orm": "0.6.98",
46
+ "@veloxts/router": "0.6.98",
47
+ "@veloxts/core": "0.6.98",
48
+ "@veloxts/validation": "0.6.98"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@prisma/client": ">=7.0.0"