@veloxts/cli 0.7.3 → 0.7.5
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 +24 -0
- package/dist/cli.js +2 -0
- package/dist/commands/sync.d.ts +22 -0
- package/dist/commands/sync.js +96 -0
- package/dist/sync/analyzer.d.ts +20 -0
- package/dist/sync/analyzer.js +277 -0
- package/dist/sync/detector.d.ts +18 -0
- package/dist/sync/detector.js +94 -0
- package/dist/sync/index.d.ts +21 -0
- package/dist/sync/index.js +310 -0
- package/dist/sync/planner.d.ts +24 -0
- package/dist/sync/planner.js +75 -0
- package/dist/sync/procedure-generator.d.ts +20 -0
- package/dist/sync/procedure-generator.js +250 -0
- package/dist/sync/prompter.d.ts +18 -0
- package/dist/sync/prompter.js +312 -0
- package/dist/sync/schema-generator.d.ts +24 -0
- package/dist/sync/schema-generator.js +213 -0
- package/dist/sync/types.d.ts +219 -0
- package/dist/sync/types.js +9 -0
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @veloxts/cli
|
|
2
2
|
|
|
3
|
+
## 0.7.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- fix(cli): address sync command review findings
|
|
8
|
+
- Updated dependencies
|
|
9
|
+
- @veloxts/auth@0.7.5
|
|
10
|
+
- @veloxts/core@0.7.5
|
|
11
|
+
- @veloxts/orm@0.7.5
|
|
12
|
+
- @veloxts/router@0.7.5
|
|
13
|
+
- @veloxts/validation@0.7.5
|
|
14
|
+
|
|
15
|
+
## 0.7.4
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- feat(cli): add velox sync command for whole-schema Prisma-to-TypeScript generation
|
|
20
|
+
- Updated dependencies
|
|
21
|
+
- @veloxts/auth@0.7.4
|
|
22
|
+
- @veloxts/core@0.7.4
|
|
23
|
+
- @veloxts/orm@0.7.4
|
|
24
|
+
- @veloxts/router@0.7.4
|
|
25
|
+
- @veloxts/validation@0.7.4
|
|
26
|
+
|
|
3
27
|
## 0.7.3
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
package/dist/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ import { createMigrateCommand } from './commands/migrate.js';
|
|
|
18
18
|
import { createOpenApiCommand } from './commands/openapi.js';
|
|
19
19
|
import { createProceduresCommand } from './commands/procedures.js';
|
|
20
20
|
import { createScheduleCommand } from './commands/schedule.js';
|
|
21
|
+
import { createSyncCommand } from './commands/sync.js';
|
|
21
22
|
import { createTenantCommand } from './commands/tenant.js';
|
|
22
23
|
import { CLI_VERSION } from './index.js';
|
|
23
24
|
/**
|
|
@@ -40,6 +41,7 @@ function createCLI() {
|
|
|
40
41
|
program.addCommand(createOpenApiCommand());
|
|
41
42
|
program.addCommand(createProceduresCommand());
|
|
42
43
|
program.addCommand(createScheduleCommand());
|
|
44
|
+
program.addCommand(createSyncCommand());
|
|
43
45
|
program.addCommand(createTenantCommand());
|
|
44
46
|
return program;
|
|
45
47
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync command - Generate Zod schemas + CRUD procedures from Prisma schema
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* velox sync [options]
|
|
6
|
+
*
|
|
7
|
+
* Options:
|
|
8
|
+
* --dry-run Preview changes without writing files
|
|
9
|
+
* --force Skip prompts, generate all models with defaults
|
|
10
|
+
* --skip-registration Skip auto-registering procedures in router
|
|
11
|
+
*
|
|
12
|
+
* Examples:
|
|
13
|
+
* velox sync Interactive sync for all models
|
|
14
|
+
* velox sync --dry-run Preview what would be generated
|
|
15
|
+
* velox sync --force Generate all models with defaults
|
|
16
|
+
* velox sync --skip-registration Skip router registration
|
|
17
|
+
*/
|
|
18
|
+
import { Command } from 'commander';
|
|
19
|
+
/**
|
|
20
|
+
* Create the `velox sync` command.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createSyncCommand(): Command;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync command - Generate Zod schemas + CRUD procedures from Prisma schema
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* velox sync [options]
|
|
6
|
+
*
|
|
7
|
+
* Options:
|
|
8
|
+
* --dry-run Preview changes without writing files
|
|
9
|
+
* --force Skip prompts, generate all models with defaults
|
|
10
|
+
* --skip-registration Skip auto-registering procedures in router
|
|
11
|
+
*
|
|
12
|
+
* Examples:
|
|
13
|
+
* velox sync Interactive sync for all models
|
|
14
|
+
* velox sync --dry-run Preview what would be generated
|
|
15
|
+
* velox sync --force Generate all models with defaults
|
|
16
|
+
* velox sync --skip-registration Skip router registration
|
|
17
|
+
*/
|
|
18
|
+
import * as p from '@clack/prompts';
|
|
19
|
+
import { Command } from 'commander';
|
|
20
|
+
import pc from 'picocolors';
|
|
21
|
+
import { ensureVeloxProject } from '../generators/index.js';
|
|
22
|
+
import { findPrismaSchema } from '../generators/utils/prisma-schema.js';
|
|
23
|
+
import { executeSync } from '../sync/index.js';
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Command Creation
|
|
26
|
+
// ============================================================================
|
|
27
|
+
/**
|
|
28
|
+
* Create the `velox sync` command.
|
|
29
|
+
*/
|
|
30
|
+
export function createSyncCommand() {
|
|
31
|
+
const cmd = new Command('sync')
|
|
32
|
+
.description('Generate Zod schemas and CRUD procedures from Prisma schema')
|
|
33
|
+
.option('-d, --dry-run', 'Preview changes without writing files', false)
|
|
34
|
+
.option('-f, --force', 'Skip prompts and generate all models with defaults', false)
|
|
35
|
+
.option('--skip-registration', 'Skip auto-registering procedures in router', false)
|
|
36
|
+
.action(async (options) => {
|
|
37
|
+
await runSync(options);
|
|
38
|
+
});
|
|
39
|
+
return cmd;
|
|
40
|
+
}
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Command Execution
|
|
43
|
+
// ============================================================================
|
|
44
|
+
/**
|
|
45
|
+
* Run the sync pipeline.
|
|
46
|
+
*/
|
|
47
|
+
async function runSync(options) {
|
|
48
|
+
const projectRoot = process.cwd();
|
|
49
|
+
try {
|
|
50
|
+
// Verify we're in a VeloxTS project
|
|
51
|
+
await ensureVeloxProject(projectRoot);
|
|
52
|
+
// Verify Prisma schema exists
|
|
53
|
+
const schemaPath = findPrismaSchema(projectRoot);
|
|
54
|
+
if (!schemaPath) {
|
|
55
|
+
p.log.error('Prisma schema not found. Ensure prisma/schema.prisma exists in your project.');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
p.intro(pc.bgCyan(pc.black(' VeloxTS Sync ')));
|
|
59
|
+
const result = await executeSync(projectRoot, {
|
|
60
|
+
dryRun: options.dryRun,
|
|
61
|
+
force: options.force,
|
|
62
|
+
skipRegistration: options.skipRegistration,
|
|
63
|
+
});
|
|
64
|
+
// Show final summary
|
|
65
|
+
const totalCreated = result.created.length;
|
|
66
|
+
const totalOverwritten = result.overwritten.length;
|
|
67
|
+
const totalRegistered = result.registered.length;
|
|
68
|
+
const totalErrors = result.errors.length;
|
|
69
|
+
if (totalErrors > 0) {
|
|
70
|
+
p.outro(pc.yellow(`Sync completed with ${totalErrors} error${totalErrors === 1 ? '' : 's'}. ` +
|
|
71
|
+
`${totalCreated} created, ${totalOverwritten} overwritten.`));
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
if (totalCreated === 0 && totalOverwritten === 0) {
|
|
75
|
+
p.outro(pc.dim('Nothing to generate.'));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const parts = [];
|
|
79
|
+
if (totalCreated > 0) {
|
|
80
|
+
parts.push(`${totalCreated} created`);
|
|
81
|
+
}
|
|
82
|
+
if (totalOverwritten > 0) {
|
|
83
|
+
parts.push(`${totalOverwritten} overwritten`);
|
|
84
|
+
}
|
|
85
|
+
if (totalRegistered > 0) {
|
|
86
|
+
parts.push(`${totalRegistered} registered`);
|
|
87
|
+
}
|
|
88
|
+
p.outro(pc.green(`Sync complete: ${parts.join(', ')}.`));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
93
|
+
p.log.error(message);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Reads a Prisma schema and produces rich `SyncModelInfo[]` metadata
|
|
5
|
+
* for every model. This is stage 1 of the `velox sync` pipeline.
|
|
6
|
+
*
|
|
7
|
+
* The analyzer re-parses the raw schema text (rather than relying solely
|
|
8
|
+
* on the existing `analyzePrismaSchema` helper) because the helper skips
|
|
9
|
+
* relation fields that carry `@relation(fields: [...])` -- exactly the
|
|
10
|
+
* ones we need to classify belongsTo relations and detect foreign keys.
|
|
11
|
+
*/
|
|
12
|
+
import type { SyncModelInfo } from './types.js';
|
|
13
|
+
/**
|
|
14
|
+
* Analyze a Prisma schema and produce sync metadata for every model.
|
|
15
|
+
*
|
|
16
|
+
* @param projectRoot - Absolute path to the project root directory
|
|
17
|
+
* @returns Array of `SyncModelInfo` objects, one per Prisma model
|
|
18
|
+
* @throws If no Prisma schema can be found
|
|
19
|
+
*/
|
|
20
|
+
export declare function analyzeSchema(projectRoot: string): SyncModelInfo[];
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Reads a Prisma schema and produces rich `SyncModelInfo[]` metadata
|
|
5
|
+
* for every model. This is stage 1 of the `velox sync` pipeline.
|
|
6
|
+
*
|
|
7
|
+
* The analyzer re-parses the raw schema text (rather than relying solely
|
|
8
|
+
* on the existing `analyzePrismaSchema` helper) because the helper skips
|
|
9
|
+
* relation fields that carry `@relation(fields: [...])` -- exactly the
|
|
10
|
+
* ones we need to classify belongsTo relations and detect foreign keys.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { analyzePrismaSchema, findPrismaSchema } from '../generators/utils/prisma-schema.js';
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Constants
|
|
16
|
+
// ============================================================================
|
|
17
|
+
/** Field names considered auto-managed by the framework */
|
|
18
|
+
const AUTO_MANAGED_NAMES = new Set(['id', 'createdAt', 'updatedAt', 'deletedAt']);
|
|
19
|
+
/** Field names considered sensitive (checked case-insensitively) */
|
|
20
|
+
const SENSITIVE_PATTERNS = [
|
|
21
|
+
'password',
|
|
22
|
+
'passwordhash',
|
|
23
|
+
'secret',
|
|
24
|
+
'secretkey',
|
|
25
|
+
'token',
|
|
26
|
+
'hash',
|
|
27
|
+
'apikey',
|
|
28
|
+
];
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Public API
|
|
31
|
+
// ============================================================================
|
|
32
|
+
/**
|
|
33
|
+
* Analyze a Prisma schema and produce sync metadata for every model.
|
|
34
|
+
*
|
|
35
|
+
* @param projectRoot - Absolute path to the project root directory
|
|
36
|
+
* @returns Array of `SyncModelInfo` objects, one per Prisma model
|
|
37
|
+
* @throws If no Prisma schema can be found
|
|
38
|
+
*/
|
|
39
|
+
export function analyzeSchema(projectRoot) {
|
|
40
|
+
const schemaPath = findPrismaSchema(projectRoot);
|
|
41
|
+
if (!schemaPath) {
|
|
42
|
+
throw new Error('Prisma schema not found. Ensure prisma/schema.prisma exists in your project.');
|
|
43
|
+
}
|
|
44
|
+
// Use the existing parser to discover model names
|
|
45
|
+
const analysis = analyzePrismaSchema(schemaPath);
|
|
46
|
+
const modelNames = analysis.models;
|
|
47
|
+
// Read the raw schema content for our own deeper parsing
|
|
48
|
+
const content = readFileSync(schemaPath, 'utf-8');
|
|
49
|
+
// Extract raw model bodies keyed by model name
|
|
50
|
+
const modelBodies = extractModelBodies(content);
|
|
51
|
+
const results = [];
|
|
52
|
+
for (const modelName of modelNames) {
|
|
53
|
+
const body = modelBodies.get(modelName);
|
|
54
|
+
if (!body)
|
|
55
|
+
continue;
|
|
56
|
+
const rawLines = parseRawLines(body);
|
|
57
|
+
const fields = buildFieldInfos(rawLines, modelNames);
|
|
58
|
+
const relations = buildRelationInfos(rawLines, modelNames);
|
|
59
|
+
const uniqueConstraints = parseUniqueConstraints(body);
|
|
60
|
+
// Detect user foreign keys: for every belongsTo pointing to 'User',
|
|
61
|
+
// mark the corresponding FK scalar field.
|
|
62
|
+
const userFkNames = new Set();
|
|
63
|
+
for (const rel of relations) {
|
|
64
|
+
if (rel.kind === 'belongsTo' && rel.relatedModel === 'User' && rel.foreignKey) {
|
|
65
|
+
userFkNames.add(rel.foreignKey);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Patch isUserForeignKey on fields
|
|
69
|
+
const patchedFields = fields.map((f) => (userFkNames.has(f.name) ? { ...f, isUserForeignKey: true } : f));
|
|
70
|
+
// Collect FK field names from all belongsTo relations
|
|
71
|
+
const fkFieldNames = new Set();
|
|
72
|
+
for (const rel of relations) {
|
|
73
|
+
if (rel.kind === 'belongsTo' && rel.foreignKey) {
|
|
74
|
+
fkFieldNames.add(rel.foreignKey);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const isJoinTable = detectJoinTable(patchedFields, fkFieldNames, uniqueConstraints);
|
|
78
|
+
const hasCreatedAt = patchedFields.some((f) => f.name === 'createdAt');
|
|
79
|
+
const hasUpdatedAt = patchedFields.some((f) => f.name === 'updatedAt');
|
|
80
|
+
results.push({
|
|
81
|
+
name: modelName,
|
|
82
|
+
fields: patchedFields,
|
|
83
|
+
relations,
|
|
84
|
+
isJoinTable,
|
|
85
|
+
hasTimestamps: hasCreatedAt && hasUpdatedAt,
|
|
86
|
+
uniqueConstraints,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return results;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Extract the body text for each model in the schema.
|
|
93
|
+
* Returns a Map from model name to the text between `{` and `}`.
|
|
94
|
+
*/
|
|
95
|
+
function extractModelBodies(content) {
|
|
96
|
+
const bodies = new Map();
|
|
97
|
+
const modelRegex = /^model\s+(\w+)\s*\{/gm;
|
|
98
|
+
for (const match of content.matchAll(modelRegex)) {
|
|
99
|
+
const modelName = match[1];
|
|
100
|
+
const openBrace = content.indexOf('{', match.index ?? 0);
|
|
101
|
+
const closeBrace = findClosingBrace(content, openBrace);
|
|
102
|
+
bodies.set(modelName, content.slice(openBrace + 1, closeBrace));
|
|
103
|
+
}
|
|
104
|
+
return bodies;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Find the matching closing `}` for an opening `{`.
|
|
108
|
+
*/
|
|
109
|
+
function findClosingBrace(content, openIndex) {
|
|
110
|
+
let depth = 0;
|
|
111
|
+
for (let i = openIndex; i < content.length; i++) {
|
|
112
|
+
if (content[i] === '{')
|
|
113
|
+
depth++;
|
|
114
|
+
else if (content[i] === '}') {
|
|
115
|
+
depth--;
|
|
116
|
+
if (depth === 0)
|
|
117
|
+
return i;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw new Error('Prisma schema has unbalanced braces — model block not properly closed');
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Parse every field-like line from a model body into `RawFieldLine` objects.
|
|
124
|
+
* Skips blank lines, comments, and `@@` directives.
|
|
125
|
+
*/
|
|
126
|
+
function parseRawLines(body) {
|
|
127
|
+
const lines = [];
|
|
128
|
+
for (const line of body.split('\n')) {
|
|
129
|
+
const trimmed = line.trim();
|
|
130
|
+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('@@'))
|
|
131
|
+
continue;
|
|
132
|
+
const fieldMatch = trimmed.match(/^(\w+)\s+(\w+)(\[\])?\??/);
|
|
133
|
+
if (!fieldMatch)
|
|
134
|
+
continue;
|
|
135
|
+
const name = fieldMatch[1];
|
|
136
|
+
const baseType = fieldMatch[2];
|
|
137
|
+
const isArray = fieldMatch[3] === '[]';
|
|
138
|
+
// Optional `?` can appear after `[]` or directly after type
|
|
139
|
+
const isOptional = /^\w+\s+\w+(\[\])?\?/.test(trimmed);
|
|
140
|
+
lines.push({ name, baseType, isArray, isOptional, rawLine: trimmed });
|
|
141
|
+
}
|
|
142
|
+
return lines;
|
|
143
|
+
}
|
|
144
|
+
// ============================================================================
|
|
145
|
+
// Internal: Field classification
|
|
146
|
+
// ============================================================================
|
|
147
|
+
/**
|
|
148
|
+
* Build `SyncFieldInfo[]` for all scalar (non-relation) fields.
|
|
149
|
+
*/
|
|
150
|
+
function buildFieldInfos(rawLines, modelNames) {
|
|
151
|
+
const fields = [];
|
|
152
|
+
for (const line of rawLines) {
|
|
153
|
+
// Skip relation fields (type matches a known model name)
|
|
154
|
+
if (modelNames.has(line.baseType))
|
|
155
|
+
continue;
|
|
156
|
+
const hasDefault = /@default\(/.test(line.rawLine) || /@updatedAt/.test(line.rawLine);
|
|
157
|
+
const isId = /@id\b/.test(line.rawLine);
|
|
158
|
+
const isUnique = /@unique\b/.test(line.rawLine);
|
|
159
|
+
const defaultValue = extractDefaultValue(line.rawLine);
|
|
160
|
+
fields.push({
|
|
161
|
+
name: line.name,
|
|
162
|
+
type: line.baseType,
|
|
163
|
+
isOptional: line.isOptional,
|
|
164
|
+
isId,
|
|
165
|
+
isUnique,
|
|
166
|
+
hasDefault,
|
|
167
|
+
defaultValue,
|
|
168
|
+
isAutoManaged: AUTO_MANAGED_NAMES.has(line.name),
|
|
169
|
+
isSensitive: SENSITIVE_PATTERNS.includes(line.name.toLowerCase()),
|
|
170
|
+
isUserForeignKey: false, // patched later
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return fields;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Extract the value string from `@default(VALUE)`.
|
|
177
|
+
* Handles nested parentheses like `@default(uuid())`.
|
|
178
|
+
* Returns `undefined` when there is no `@default(...)`.
|
|
179
|
+
*/
|
|
180
|
+
function extractDefaultValue(rawLine) {
|
|
181
|
+
const marker = '@default(';
|
|
182
|
+
const start = rawLine.indexOf(marker);
|
|
183
|
+
if (start === -1)
|
|
184
|
+
return undefined;
|
|
185
|
+
const valueStart = start + marker.length;
|
|
186
|
+
let depth = 1;
|
|
187
|
+
let i = valueStart;
|
|
188
|
+
while (i < rawLine.length && depth > 0) {
|
|
189
|
+
if (rawLine[i] === '(')
|
|
190
|
+
depth++;
|
|
191
|
+
else if (rawLine[i] === ')')
|
|
192
|
+
depth--;
|
|
193
|
+
if (depth > 0)
|
|
194
|
+
i++;
|
|
195
|
+
}
|
|
196
|
+
return rawLine.slice(valueStart, i);
|
|
197
|
+
}
|
|
198
|
+
// ============================================================================
|
|
199
|
+
// Internal: Relation classification
|
|
200
|
+
// ============================================================================
|
|
201
|
+
/**
|
|
202
|
+
* Build `SyncRelationInfo[]` for all relation fields.
|
|
203
|
+
*
|
|
204
|
+
* Unlike the existing parser, this does NOT skip `@relation(fields: [...])`
|
|
205
|
+
* lines -- those are classified as `belongsTo`.
|
|
206
|
+
*/
|
|
207
|
+
function buildRelationInfos(rawLines, modelNames) {
|
|
208
|
+
const relations = [];
|
|
209
|
+
for (const line of rawLines) {
|
|
210
|
+
if (!modelNames.has(line.baseType))
|
|
211
|
+
continue;
|
|
212
|
+
const hasFkRelation = /@relation\([^)]*fields:\s*\[/.test(line.rawLine);
|
|
213
|
+
if (hasFkRelation) {
|
|
214
|
+
// belongsTo: this model owns the FK
|
|
215
|
+
const fkMatch = line.rawLine.match(/@relation\([^)]*fields:\s*\[(\w+)\]/);
|
|
216
|
+
const foreignKey = fkMatch ? fkMatch[1] : undefined;
|
|
217
|
+
relations.push({
|
|
218
|
+
name: line.name,
|
|
219
|
+
relatedModel: line.baseType,
|
|
220
|
+
kind: 'belongsTo',
|
|
221
|
+
foreignKey,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
else if (line.isArray) {
|
|
225
|
+
// hasMany
|
|
226
|
+
relations.push({
|
|
227
|
+
name: line.name,
|
|
228
|
+
relatedModel: line.baseType,
|
|
229
|
+
kind: 'hasMany',
|
|
230
|
+
foreignKey: undefined,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
// hasOne (non-array, no FK annotation)
|
|
235
|
+
relations.push({
|
|
236
|
+
name: line.name,
|
|
237
|
+
relatedModel: line.baseType,
|
|
238
|
+
kind: 'hasOne',
|
|
239
|
+
foreignKey: undefined,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return relations;
|
|
244
|
+
}
|
|
245
|
+
// ============================================================================
|
|
246
|
+
// Internal: Unique constraints & join table detection
|
|
247
|
+
// ============================================================================
|
|
248
|
+
/**
|
|
249
|
+
* Parse `@@unique([field1, field2])` directives from a model body.
|
|
250
|
+
*/
|
|
251
|
+
function parseUniqueConstraints(body) {
|
|
252
|
+
const constraints = [];
|
|
253
|
+
const regex = /@@unique\(\[([^\]]+)\]\)/g;
|
|
254
|
+
for (const match of body.matchAll(regex)) {
|
|
255
|
+
const fieldNames = match[1]
|
|
256
|
+
.split(',')
|
|
257
|
+
.map((s) => s.trim())
|
|
258
|
+
.filter((s) => s.length > 0);
|
|
259
|
+
constraints.push(fieldNames);
|
|
260
|
+
}
|
|
261
|
+
return constraints;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Determine if a model is a many-to-many join table.
|
|
265
|
+
*
|
|
266
|
+
* A model is a join table when:
|
|
267
|
+
* 1. Every non-auto-managed scalar field is a foreign key
|
|
268
|
+
* 2. There is at least one `@@unique` compound constraint
|
|
269
|
+
*/
|
|
270
|
+
function detectJoinTable(fields, fkFieldNames, uniqueConstraints) {
|
|
271
|
+
if (uniqueConstraints.length === 0)
|
|
272
|
+
return false;
|
|
273
|
+
const nonAutoManagedScalars = fields.filter((f) => !f.isAutoManaged);
|
|
274
|
+
if (nonAutoManagedScalars.length === 0)
|
|
275
|
+
return false;
|
|
276
|
+
return nonAutoManagedScalars.every((f) => fkFieldNames.has(f.name));
|
|
277
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Detector
|
|
3
|
+
*
|
|
4
|
+
* Scans a project's `src/procedures/` and `src/schemas/` directories to
|
|
5
|
+
* discover existing generated files. The result is an `ExistingCodeMap`
|
|
6
|
+
* consumed by the prompter stage to decide whether each model needs
|
|
7
|
+
* generation, regeneration, or can be skipped.
|
|
8
|
+
*/
|
|
9
|
+
import type { ExistingCodeMap, SyncModelInfo } from './types.js';
|
|
10
|
+
/**
|
|
11
|
+
* Detect existing procedure and schema files that correspond to known
|
|
12
|
+
* Prisma models.
|
|
13
|
+
*
|
|
14
|
+
* @param projectRoot - Absolute path to the project root directory
|
|
15
|
+
* @param models - Model metadata produced by the analyzer stage
|
|
16
|
+
* @returns Map of model names to their existing file paths
|
|
17
|
+
*/
|
|
18
|
+
export declare function detectExisting(projectRoot: string, models: readonly SyncModelInfo[]): ExistingCodeMap;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Detector
|
|
3
|
+
*
|
|
4
|
+
* Scans a project's `src/procedures/` and `src/schemas/` directories to
|
|
5
|
+
* discover existing generated files. The result is an `ExistingCodeMap`
|
|
6
|
+
* consumed by the prompter stage to decide whether each model needs
|
|
7
|
+
* generation, regeneration, or can be skipped.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { singularize, toPascalCase } from '../generators/utils/naming.js';
|
|
12
|
+
/**
|
|
13
|
+
* Detect existing procedure and schema files that correspond to known
|
|
14
|
+
* Prisma models.
|
|
15
|
+
*
|
|
16
|
+
* @param projectRoot - Absolute path to the project root directory
|
|
17
|
+
* @param models - Model metadata produced by the analyzer stage
|
|
18
|
+
* @returns Map of model names to their existing file paths
|
|
19
|
+
*/
|
|
20
|
+
export function detectExisting(projectRoot, models) {
|
|
21
|
+
const modelNames = new Set(models.map((m) => m.name));
|
|
22
|
+
const procedures = scanProcedures(projectRoot, modelNames);
|
|
23
|
+
const schemas = scanSchemas(projectRoot, modelNames);
|
|
24
|
+
return { procedures, schemas };
|
|
25
|
+
}
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Internal Helpers
|
|
28
|
+
// ============================================================================
|
|
29
|
+
/**
|
|
30
|
+
* Scan `src/procedures/` for `.ts` files that match known model names.
|
|
31
|
+
*
|
|
32
|
+
* Excludes `index.ts` and anything inside `__tests__/`.
|
|
33
|
+
* Converts kebab-case/plural filenames to PascalCase singular for matching.
|
|
34
|
+
*/
|
|
35
|
+
function scanProcedures(projectRoot, modelNames) {
|
|
36
|
+
const proceduresDir = join(projectRoot, 'src', 'procedures');
|
|
37
|
+
const result = new Map();
|
|
38
|
+
if (!existsSync(proceduresDir)) {
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
const entries = readdirSync(proceduresDir, { withFileTypes: true });
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
// Skip directories (including __tests__/) and non-.ts files
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const fileName = entry.name;
|
|
48
|
+
// Skip non-.ts files and index.ts
|
|
49
|
+
if (!fileName.endsWith('.ts') || fileName === 'index.ts') {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// Extract base name: "users.ts" -> "users", "friend-requests.ts" -> "friend-requests"
|
|
53
|
+
const baseName = fileName.slice(0, -3);
|
|
54
|
+
// Convert to PascalCase singular: "users" -> "User", "friend-requests" -> "FriendRequest"
|
|
55
|
+
const modelName = toPascalCase(singularize(baseName));
|
|
56
|
+
if (modelNames.has(modelName)) {
|
|
57
|
+
result.set(modelName, join(proceduresDir, fileName));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Scan `src/schemas/` for `.schema.ts` files that match known model names.
|
|
64
|
+
*
|
|
65
|
+
* Excludes `index.ts` and anything inside `__tests__/`.
|
|
66
|
+
* Strips the `.schema.ts` suffix, then converts to PascalCase singular.
|
|
67
|
+
*/
|
|
68
|
+
function scanSchemas(projectRoot, modelNames) {
|
|
69
|
+
const schemasDir = join(projectRoot, 'src', 'schemas');
|
|
70
|
+
const result = new Map();
|
|
71
|
+
if (!existsSync(schemasDir)) {
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
const entries = readdirSync(schemasDir, { withFileTypes: true });
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
// Skip directories (including __tests__/) and non-.schema.ts files
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const fileName = entry.name;
|
|
81
|
+
// Skip files that are not *.schema.ts, and skip index.ts
|
|
82
|
+
if (!fileName.endsWith('.schema.ts') || fileName === 'index.ts') {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// Extract base name: "post.schema.ts" -> "post", "friend-requests.schema.ts" -> "friend-requests"
|
|
86
|
+
const baseName = fileName.slice(0, -'.schema.ts'.length);
|
|
87
|
+
// Convert to PascalCase singular: "posts" -> "Post", "friend-requests" -> "FriendRequest"
|
|
88
|
+
const modelName = toPascalCase(singularize(baseName));
|
|
89
|
+
if (modelNames.has(modelName)) {
|
|
90
|
+
result.set(modelName, join(schemasDir, fileName));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Orchestrator
|
|
3
|
+
*
|
|
4
|
+
* Wires together the 5-stage `velox sync` pipeline:
|
|
5
|
+
* 1. Analyze Prisma schema -> SyncModelInfo[]
|
|
6
|
+
* 2. Detect existing code -> ExistingCodeMap
|
|
7
|
+
* 3. Prompt user for choices -> ModelChoices[]
|
|
8
|
+
* 4. Build generation plan -> SyncPlan
|
|
9
|
+
* 5. Generate files + register in router
|
|
10
|
+
*
|
|
11
|
+
* Supports --dry-run, --force, and --skip-registration flags.
|
|
12
|
+
*/
|
|
13
|
+
import type { SyncCommandOptions, SyncResult } from './types.js';
|
|
14
|
+
/**
|
|
15
|
+
* Execute the full sync pipeline.
|
|
16
|
+
*
|
|
17
|
+
* @param projectRoot - Absolute path to the project root directory
|
|
18
|
+
* @param options - CLI flags (dryRun, force, skipRegistration)
|
|
19
|
+
* @returns Summary of files created, overwritten, skipped, registered, and errors
|
|
20
|
+
*/
|
|
21
|
+
export declare function executeSync(projectRoot: string, options: SyncCommandOptions): Promise<SyncResult>;
|