@vertz/compiler 0.2.0 → 0.2.3
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 +8 -700
- package/dist/index.d.ts +104 -2
- package/dist/index.js +650 -6
- package/package.json +10 -9
package/dist/index.js
CHANGED
|
@@ -1272,7 +1272,483 @@ function joinPaths(prefix, path) {
|
|
|
1272
1272
|
function sanitizePath(path) {
|
|
1273
1273
|
return path.replace(/^\//, "").replace(/[/:.]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "") || "root";
|
|
1274
1274
|
}
|
|
1275
|
+
// src/analyzers/entity-analyzer.ts
|
|
1276
|
+
import { SyntaxKind as SyntaxKind9 } from "ts-morph";
|
|
1277
|
+
var ENTITY_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
1278
|
+
var CRUD_OPS = ["list", "get", "create", "update", "delete"];
|
|
1279
|
+
|
|
1280
|
+
class EntityAnalyzer extends BaseAnalyzer {
|
|
1281
|
+
debug(msg) {
|
|
1282
|
+
if (process.env["VERTZ_DEBUG"]?.includes("entities")) {
|
|
1283
|
+
console.log(`[entity-analyzer] ${msg}`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
async analyze() {
|
|
1287
|
+
const entities = [];
|
|
1288
|
+
const seenNames = new Map;
|
|
1289
|
+
const files = this.project.getSourceFiles();
|
|
1290
|
+
this.debug(`Scanning ${files.length} source files...`);
|
|
1291
|
+
for (const file of files) {
|
|
1292
|
+
const calls = this.findEntityCalls(file);
|
|
1293
|
+
for (const call of calls) {
|
|
1294
|
+
const entity = this.extractEntity(file, call);
|
|
1295
|
+
if (!entity)
|
|
1296
|
+
continue;
|
|
1297
|
+
const existing = seenNames.get(entity.name);
|
|
1298
|
+
if (existing) {
|
|
1299
|
+
this.addDiagnostic({
|
|
1300
|
+
code: "ENTITY_DUPLICATE_NAME",
|
|
1301
|
+
severity: "error",
|
|
1302
|
+
message: `Entity "${entity.name}" is already defined at ${existing.sourceFile}:${existing.sourceLine}`,
|
|
1303
|
+
...getSourceLocation(call)
|
|
1304
|
+
});
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
seenNames.set(entity.name, entity);
|
|
1308
|
+
entities.push(entity);
|
|
1309
|
+
this.debug(`Detected entity: "${entity.name}" at ${entity.sourceFile}:${entity.sourceLine}`);
|
|
1310
|
+
this.debug(` model: ${entity.modelRef.variableName} (resolved: ${entity.modelRef.schemaRefs.resolved ? "✅" : "❌"})`);
|
|
1311
|
+
const accessStatus = CRUD_OPS.map((op) => {
|
|
1312
|
+
const kind = entity.access[op];
|
|
1313
|
+
return `${op} ${kind === "false" ? "✗" : "✓"}`;
|
|
1314
|
+
}).join(", ");
|
|
1315
|
+
this.debug(` access: ${accessStatus}`);
|
|
1316
|
+
if (entity.hooks.before.length > 0 || entity.hooks.after.length > 0) {
|
|
1317
|
+
this.debug(` hooks: before[${entity.hooks.before.join(",")}], after[${entity.hooks.after.join(",")}]`);
|
|
1318
|
+
}
|
|
1319
|
+
if (entity.actions.length > 0) {
|
|
1320
|
+
this.debug(` actions: ${entity.actions.map((a) => a.name).join(", ")}`);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
return { entities };
|
|
1325
|
+
}
|
|
1326
|
+
findEntityCalls(file) {
|
|
1327
|
+
const validCalls = [];
|
|
1328
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind9.CallExpression)) {
|
|
1329
|
+
const expr = call.getExpression();
|
|
1330
|
+
if (expr.isKind(SyntaxKind9.Identifier)) {
|
|
1331
|
+
const isValid = isFromImport(expr, "@vertz/server");
|
|
1332
|
+
if (!isValid && expr.getText() === "entity") {
|
|
1333
|
+
this.addDiagnostic({
|
|
1334
|
+
code: "ENTITY_UNRESOLVED_IMPORT",
|
|
1335
|
+
severity: "error",
|
|
1336
|
+
message: "entity() call does not resolve to @vertz/server",
|
|
1337
|
+
...getSourceLocation(call)
|
|
1338
|
+
});
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
if (isValid) {
|
|
1342
|
+
validCalls.push(call);
|
|
1343
|
+
}
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
if (expr.isKind(SyntaxKind9.PropertyAccessExpression)) {
|
|
1347
|
+
const propName = expr.getName();
|
|
1348
|
+
if (propName !== "entity")
|
|
1349
|
+
continue;
|
|
1350
|
+
const obj = expr.getExpression();
|
|
1351
|
+
if (!obj.isKind(SyntaxKind9.Identifier))
|
|
1352
|
+
continue;
|
|
1353
|
+
const sourceFile = obj.getSourceFile();
|
|
1354
|
+
const importDecl = sourceFile.getImportDeclarations().find((d) => d.getModuleSpecifierValue() === "@vertz/server" && d.getNamespaceImport()?.getText() === obj.getText());
|
|
1355
|
+
if (importDecl) {
|
|
1356
|
+
validCalls.push(call);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
return validCalls;
|
|
1361
|
+
}
|
|
1362
|
+
extractEntity(_file, call) {
|
|
1363
|
+
const args = call.getArguments();
|
|
1364
|
+
const loc = getSourceLocation(call);
|
|
1365
|
+
if (args.length < 2) {
|
|
1366
|
+
this.addDiagnostic({
|
|
1367
|
+
code: "ENTITY_MISSING_ARGS",
|
|
1368
|
+
severity: "error",
|
|
1369
|
+
message: "entity() requires two arguments: name and config",
|
|
1370
|
+
...loc
|
|
1371
|
+
});
|
|
1372
|
+
return null;
|
|
1373
|
+
}
|
|
1374
|
+
const name = getStringValue(args[0]);
|
|
1375
|
+
if (name === null) {
|
|
1376
|
+
this.addDiagnostic({
|
|
1377
|
+
code: "ENTITY_NON_LITERAL_NAME",
|
|
1378
|
+
severity: "error",
|
|
1379
|
+
message: "entity() name must be a string literal",
|
|
1380
|
+
...loc
|
|
1381
|
+
});
|
|
1382
|
+
return null;
|
|
1383
|
+
}
|
|
1384
|
+
if (!ENTITY_NAME_PATTERN.test(name)) {
|
|
1385
|
+
this.addDiagnostic({
|
|
1386
|
+
code: "ENTITY_INVALID_NAME",
|
|
1387
|
+
severity: "error",
|
|
1388
|
+
message: `Entity name must match /^[a-z][a-z0-9-]*$/. Got: "${name}"`,
|
|
1389
|
+
...loc
|
|
1390
|
+
});
|
|
1391
|
+
return null;
|
|
1392
|
+
}
|
|
1393
|
+
const configObj = extractObjectLiteral(call, 1);
|
|
1394
|
+
if (!configObj) {
|
|
1395
|
+
this.addDiagnostic({
|
|
1396
|
+
code: "ENTITY_CONFIG_NOT_OBJECT",
|
|
1397
|
+
severity: "warning",
|
|
1398
|
+
message: "entity() config must be an object literal for static analysis",
|
|
1399
|
+
...loc
|
|
1400
|
+
});
|
|
1401
|
+
return null;
|
|
1402
|
+
}
|
|
1403
|
+
const modelRef = this.extractModelRef(configObj, loc);
|
|
1404
|
+
if (!modelRef)
|
|
1405
|
+
return null;
|
|
1406
|
+
const access = this.extractAccess(configObj);
|
|
1407
|
+
const hooks = this.extractHooks(configObj);
|
|
1408
|
+
const actions = this.extractActions(configObj);
|
|
1409
|
+
const relations = this.extractRelations(configObj);
|
|
1410
|
+
for (const action of actions) {
|
|
1411
|
+
if (CRUD_OPS.includes(action.name)) {
|
|
1412
|
+
this.addDiagnostic({
|
|
1413
|
+
code: "ENTITY_ACTION_NAME_COLLISION",
|
|
1414
|
+
severity: "error",
|
|
1415
|
+
message: `Custom action "${action.name}" collides with built-in CRUD operation`,
|
|
1416
|
+
...action
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
for (const customOp of Object.keys(access.custom)) {
|
|
1421
|
+
if (!actions.some((a) => a.name === customOp)) {
|
|
1422
|
+
this.addDiagnostic({
|
|
1423
|
+
code: "ENTITY_UNKNOWN_ACCESS_OP",
|
|
1424
|
+
severity: "warning",
|
|
1425
|
+
message: `Unknown access operation "${customOp}" — not a CRUD op or custom action`,
|
|
1426
|
+
...loc
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
return { name, modelRef, access, hooks, actions, relations, ...loc };
|
|
1431
|
+
}
|
|
1432
|
+
extractModelRef(configObj, loc) {
|
|
1433
|
+
const modelExpr = getPropertyValue(configObj, "model");
|
|
1434
|
+
if (!modelExpr) {
|
|
1435
|
+
this.addDiagnostic({
|
|
1436
|
+
code: "ENTITY_MISSING_MODEL",
|
|
1437
|
+
severity: "error",
|
|
1438
|
+
message: "entity() requires a model property",
|
|
1439
|
+
...loc
|
|
1440
|
+
});
|
|
1441
|
+
return null;
|
|
1442
|
+
}
|
|
1443
|
+
const variableName = modelExpr.isKind(SyntaxKind9.Identifier) ? modelExpr.getText() : modelExpr.getText();
|
|
1444
|
+
let importSource;
|
|
1445
|
+
if (modelExpr.isKind(SyntaxKind9.Identifier)) {
|
|
1446
|
+
const importInfo = this.findImportForIdentifier(modelExpr);
|
|
1447
|
+
if (importInfo) {
|
|
1448
|
+
importSource = importInfo.importDecl.getModuleSpecifierValue();
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const schemaRefs = this.resolveModelSchemas(modelExpr);
|
|
1452
|
+
return { variableName, importSource, schemaRefs };
|
|
1453
|
+
}
|
|
1454
|
+
findImportForIdentifier(identifier) {
|
|
1455
|
+
const sourceFile = identifier.getSourceFile();
|
|
1456
|
+
const name = identifier.getText();
|
|
1457
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
1458
|
+
for (const specifier of importDecl.getNamedImports()) {
|
|
1459
|
+
const localName = specifier.getAliasNode()?.getText() ?? specifier.getName();
|
|
1460
|
+
if (localName === name) {
|
|
1461
|
+
return { importDecl, originalName: specifier.getName() };
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
const nsImport = importDecl.getNamespaceImport();
|
|
1465
|
+
if (nsImport && nsImport.getText() === name) {
|
|
1466
|
+
return { importDecl, originalName: "*" };
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
return null;
|
|
1470
|
+
}
|
|
1471
|
+
resolveModelSchemas(modelExpr) {
|
|
1472
|
+
try {
|
|
1473
|
+
const modelType = modelExpr.getType();
|
|
1474
|
+
const schemasProp = modelType.getProperty("schemas");
|
|
1475
|
+
if (!schemasProp)
|
|
1476
|
+
return { resolved: false };
|
|
1477
|
+
const schemasType = schemasProp.getTypeAtLocation(modelExpr);
|
|
1478
|
+
const response = this.extractSchemaType(schemasType, "response", modelExpr);
|
|
1479
|
+
const createInput = this.extractSchemaType(schemasType, "createInput", modelExpr);
|
|
1480
|
+
const updateInput = this.extractSchemaType(schemasType, "updateInput", modelExpr);
|
|
1481
|
+
return {
|
|
1482
|
+
response,
|
|
1483
|
+
createInput,
|
|
1484
|
+
updateInput,
|
|
1485
|
+
resolved: response !== undefined || createInput !== undefined || updateInput !== undefined
|
|
1486
|
+
};
|
|
1487
|
+
} catch {
|
|
1488
|
+
return { resolved: false };
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
extractSchemaType(parentType, propertyName, location) {
|
|
1492
|
+
const prop = parentType.getProperty(propertyName);
|
|
1493
|
+
if (!prop)
|
|
1494
|
+
return;
|
|
1495
|
+
const propType = prop.getTypeAtLocation(location);
|
|
1496
|
+
const resolvedFields = this.resolveFieldsFromSchemaType(propType, location);
|
|
1497
|
+
const jsonSchema = this.buildJsonSchema(resolvedFields);
|
|
1498
|
+
return {
|
|
1499
|
+
kind: "inline",
|
|
1500
|
+
sourceFile: location.getSourceFile().getFilePath(),
|
|
1501
|
+
jsonSchema,
|
|
1502
|
+
resolvedFields
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
buildJsonSchema(resolvedFields) {
|
|
1506
|
+
if (!resolvedFields || resolvedFields.length === 0) {
|
|
1507
|
+
return {};
|
|
1508
|
+
}
|
|
1509
|
+
const properties = {};
|
|
1510
|
+
const required = [];
|
|
1511
|
+
for (const field of resolvedFields) {
|
|
1512
|
+
const fieldSchema = this.tsTypeToJsonSchema(field.tsType);
|
|
1513
|
+
properties[field.name] = fieldSchema;
|
|
1514
|
+
if (!field.optional) {
|
|
1515
|
+
required.push(field.name);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
return {
|
|
1519
|
+
type: "object",
|
|
1520
|
+
properties,
|
|
1521
|
+
...required.length > 0 ? { required } : {}
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
tsTypeToJsonSchema(tsType) {
|
|
1525
|
+
switch (tsType) {
|
|
1526
|
+
case "string":
|
|
1527
|
+
return { type: "string" };
|
|
1528
|
+
case "number":
|
|
1529
|
+
return { type: "number" };
|
|
1530
|
+
case "boolean":
|
|
1531
|
+
return { type: "boolean" };
|
|
1532
|
+
case "date":
|
|
1533
|
+
return { type: "string", format: "date-time" };
|
|
1534
|
+
case "unknown":
|
|
1535
|
+
default:
|
|
1536
|
+
return {};
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
resolveFieldsFromSchemaType(schemaType, location) {
|
|
1540
|
+
try {
|
|
1541
|
+
const parseProp = schemaType.getProperty("parse");
|
|
1542
|
+
if (!parseProp)
|
|
1543
|
+
return;
|
|
1544
|
+
const parseType = parseProp.getTypeAtLocation(location);
|
|
1545
|
+
const callSignatures = parseType.getCallSignatures();
|
|
1546
|
+
if (callSignatures.length === 0)
|
|
1547
|
+
return;
|
|
1548
|
+
const returnType = callSignatures[0]?.getReturnType();
|
|
1549
|
+
if (!returnType)
|
|
1550
|
+
return;
|
|
1551
|
+
const dataType = this.unwrapResultType(returnType, location);
|
|
1552
|
+
if (!dataType)
|
|
1553
|
+
return;
|
|
1554
|
+
const properties = dataType.getProperties();
|
|
1555
|
+
if (properties.length === 0)
|
|
1556
|
+
return;
|
|
1557
|
+
const fields = [];
|
|
1558
|
+
for (const fieldProp of properties) {
|
|
1559
|
+
const name = fieldProp.getName();
|
|
1560
|
+
const fieldType = fieldProp.getTypeAtLocation(location);
|
|
1561
|
+
const optional = fieldProp.isOptional();
|
|
1562
|
+
const tsType = this.mapTsType(fieldType);
|
|
1563
|
+
fields.push({ name, tsType, optional });
|
|
1564
|
+
}
|
|
1565
|
+
return fields;
|
|
1566
|
+
} catch {
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
unwrapResultType(type, location) {
|
|
1571
|
+
if (type.isUnion()) {
|
|
1572
|
+
for (const member of type.getUnionTypes()) {
|
|
1573
|
+
const dataProp2 = member.getProperty("data");
|
|
1574
|
+
if (dataProp2) {
|
|
1575
|
+
return dataProp2.getTypeAtLocation(location);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
const dataProp = type.getProperty("data");
|
|
1581
|
+
if (dataProp) {
|
|
1582
|
+
return dataProp.getTypeAtLocation(location);
|
|
1583
|
+
}
|
|
1584
|
+
return type;
|
|
1585
|
+
}
|
|
1586
|
+
mapTsType(type) {
|
|
1587
|
+
const typeText = type.getText();
|
|
1588
|
+
if (type.isUnion()) {
|
|
1589
|
+
const nonUndefined = type.getUnionTypes().filter((t) => !t.isUndefined());
|
|
1590
|
+
if (nonUndefined.length === 1 && nonUndefined[0]) {
|
|
1591
|
+
return this.mapTsType(nonUndefined[0]);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
if (type.isString() || type.isStringLiteral())
|
|
1595
|
+
return "string";
|
|
1596
|
+
if (type.isNumber() || type.isNumberLiteral())
|
|
1597
|
+
return "number";
|
|
1598
|
+
if (type.isBoolean() || type.isBooleanLiteral())
|
|
1599
|
+
return "boolean";
|
|
1600
|
+
if (typeText === "Date")
|
|
1601
|
+
return "date";
|
|
1602
|
+
return "unknown";
|
|
1603
|
+
}
|
|
1604
|
+
extractAccess(configObj) {
|
|
1605
|
+
const defaults = {
|
|
1606
|
+
list: "none",
|
|
1607
|
+
get: "none",
|
|
1608
|
+
create: "none",
|
|
1609
|
+
update: "none",
|
|
1610
|
+
delete: "none",
|
|
1611
|
+
custom: {}
|
|
1612
|
+
};
|
|
1613
|
+
const accessExpr = getPropertyValue(configObj, "access");
|
|
1614
|
+
if (!accessExpr || !accessExpr.isKind(SyntaxKind9.ObjectLiteralExpression))
|
|
1615
|
+
return defaults;
|
|
1616
|
+
const result = { ...defaults };
|
|
1617
|
+
const knownOps = new Set([...CRUD_OPS]);
|
|
1618
|
+
for (const { name, value } of getProperties(accessExpr)) {
|
|
1619
|
+
const kind = this.classifyAccessRule(value);
|
|
1620
|
+
if (knownOps.has(name)) {
|
|
1621
|
+
result[name] = kind;
|
|
1622
|
+
} else {
|
|
1623
|
+
result.custom[name] = kind;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
return result;
|
|
1627
|
+
}
|
|
1628
|
+
classifyAccessRule(expr) {
|
|
1629
|
+
const boolVal = getBooleanValue(expr);
|
|
1630
|
+
if (boolVal === false)
|
|
1631
|
+
return "false";
|
|
1632
|
+
if (boolVal === true)
|
|
1633
|
+
return "none";
|
|
1634
|
+
return "function";
|
|
1635
|
+
}
|
|
1636
|
+
extractHooks(configObj) {
|
|
1637
|
+
const hooks = { before: [], after: [] };
|
|
1638
|
+
const beforeExpr = getPropertyValue(configObj, "before");
|
|
1639
|
+
if (beforeExpr?.isKind(SyntaxKind9.ObjectLiteralExpression)) {
|
|
1640
|
+
for (const { name } of getProperties(beforeExpr)) {
|
|
1641
|
+
if (name === "create" || name === "update")
|
|
1642
|
+
hooks.before.push(name);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
const afterExpr = getPropertyValue(configObj, "after");
|
|
1646
|
+
if (afterExpr?.isKind(SyntaxKind9.ObjectLiteralExpression)) {
|
|
1647
|
+
for (const { name } of getProperties(afterExpr)) {
|
|
1648
|
+
if (name === "create" || name === "update" || name === "delete")
|
|
1649
|
+
hooks.after.push(name);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
return hooks;
|
|
1653
|
+
}
|
|
1654
|
+
extractActions(configObj) {
|
|
1655
|
+
const actionsExpr = getPropertyValue(configObj, "actions");
|
|
1656
|
+
if (!actionsExpr?.isKind(SyntaxKind9.ObjectLiteralExpression))
|
|
1657
|
+
return [];
|
|
1658
|
+
return getProperties(actionsExpr).map(({ name, value }) => {
|
|
1659
|
+
const actionObj = value.isKind(SyntaxKind9.ObjectLiteralExpression) ? value : null;
|
|
1660
|
+
const loc = getSourceLocation(value);
|
|
1661
|
+
const bodyExpr = actionObj ? getPropertyValue(actionObj, "body") : null;
|
|
1662
|
+
const responseExpr = actionObj ? getPropertyValue(actionObj, "response") : null;
|
|
1663
|
+
if (!bodyExpr && !responseExpr) {
|
|
1664
|
+
this.addDiagnostic({
|
|
1665
|
+
code: "ENTITY_ACTION_MISSING_SCHEMA",
|
|
1666
|
+
severity: "warning",
|
|
1667
|
+
message: `Custom action "${name}" is missing body and response schema`,
|
|
1668
|
+
...loc
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
const body = bodyExpr ? this.resolveSchemaFromExpression(bodyExpr, loc) : undefined;
|
|
1672
|
+
const response = responseExpr ? this.resolveSchemaFromExpression(responseExpr, loc) : undefined;
|
|
1673
|
+
const methodExpr = actionObj ? getPropertyValue(actionObj, "method") : null;
|
|
1674
|
+
let method = "POST";
|
|
1675
|
+
if (methodExpr) {
|
|
1676
|
+
const methodStr = getStringValue(methodExpr);
|
|
1677
|
+
const validMethods = [
|
|
1678
|
+
"GET",
|
|
1679
|
+
"POST",
|
|
1680
|
+
"PUT",
|
|
1681
|
+
"DELETE",
|
|
1682
|
+
"PATCH",
|
|
1683
|
+
"HEAD",
|
|
1684
|
+
"OPTIONS"
|
|
1685
|
+
];
|
|
1686
|
+
if (methodStr && validMethods.includes(methodStr)) {
|
|
1687
|
+
method = methodStr;
|
|
1688
|
+
} else {
|
|
1689
|
+
this.addDiagnostic({
|
|
1690
|
+
code: "ENTITY_ACTION_INVALID_METHOD",
|
|
1691
|
+
severity: "error",
|
|
1692
|
+
message: `Custom action "${name}" has invalid method "${methodStr ?? "(non-string)"}" — must be one of GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS`,
|
|
1693
|
+
...loc
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
const pathExpr = actionObj ? getPropertyValue(actionObj, "path") : null;
|
|
1698
|
+
const path = pathExpr ? getStringValue(pathExpr) ?? undefined : undefined;
|
|
1699
|
+
const queryExpr = actionObj ? getPropertyValue(actionObj, "query") : null;
|
|
1700
|
+
const queryRef = queryExpr ? this.resolveSchemaFromExpression(queryExpr, loc) : undefined;
|
|
1701
|
+
const paramsExpr = actionObj ? getPropertyValue(actionObj, "params") : null;
|
|
1702
|
+
const paramsRef = paramsExpr ? this.resolveSchemaFromExpression(paramsExpr, loc) : undefined;
|
|
1703
|
+
const headersExpr = actionObj ? getPropertyValue(actionObj, "headers") : null;
|
|
1704
|
+
const headersRef = headersExpr ? this.resolveSchemaFromExpression(headersExpr, loc) : undefined;
|
|
1705
|
+
return {
|
|
1706
|
+
name,
|
|
1707
|
+
method,
|
|
1708
|
+
path,
|
|
1709
|
+
params: paramsRef,
|
|
1710
|
+
query: queryRef,
|
|
1711
|
+
headers: headersRef,
|
|
1712
|
+
body,
|
|
1713
|
+
response,
|
|
1714
|
+
...loc
|
|
1715
|
+
};
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
resolveSchemaFromExpression(expr, loc) {
|
|
1719
|
+
if (expr.isKind(SyntaxKind9.Identifier)) {
|
|
1720
|
+
const varName = expr.getText();
|
|
1721
|
+
return { kind: "named", schemaName: varName, sourceFile: loc.sourceFile };
|
|
1722
|
+
}
|
|
1723
|
+
try {
|
|
1724
|
+
const typeText = expr.getType().getText();
|
|
1725
|
+
return { kind: "inline", sourceFile: loc.sourceFile, jsonSchema: { __typeText: typeText } };
|
|
1726
|
+
} catch {
|
|
1727
|
+
return { kind: "inline", sourceFile: loc.sourceFile };
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
extractRelations(configObj) {
|
|
1731
|
+
const relExpr = getPropertyValue(configObj, "relations");
|
|
1732
|
+
if (!relExpr?.isKind(SyntaxKind9.ObjectLiteralExpression))
|
|
1733
|
+
return [];
|
|
1734
|
+
return getProperties(relExpr).filter(({ value }) => {
|
|
1735
|
+
const boolVal = getBooleanValue(value);
|
|
1736
|
+
return boolVal !== false;
|
|
1737
|
+
}).map(({ name, value }) => {
|
|
1738
|
+
const boolVal = getBooleanValue(value);
|
|
1739
|
+
if (boolVal === true)
|
|
1740
|
+
return { name, selection: "all" };
|
|
1741
|
+
if (value.isKind(SyntaxKind9.ObjectLiteralExpression)) {
|
|
1742
|
+
const fields = getProperties(value).map((p) => p.name);
|
|
1743
|
+
return { name, selection: fields };
|
|
1744
|
+
}
|
|
1745
|
+
return { name, selection: "all" };
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1275
1749
|
// src/compiler.ts
|
|
1750
|
+
import { mkdir } from "node:fs/promises";
|
|
1751
|
+
import { resolve } from "node:path";
|
|
1276
1752
|
import { Project } from "ts-morph";
|
|
1277
1753
|
|
|
1278
1754
|
// src/config.ts
|
|
@@ -1863,6 +2339,7 @@ function createEmptyAppIR() {
|
|
|
1863
2339
|
modules: [],
|
|
1864
2340
|
middleware: [],
|
|
1865
2341
|
schemas: [],
|
|
2342
|
+
entities: [],
|
|
1866
2343
|
dependencyGraph: createEmptyDependencyGraph(),
|
|
1867
2344
|
diagnostics: []
|
|
1868
2345
|
};
|
|
@@ -1894,6 +2371,162 @@ function addDiagnosticsToIR(ir, diagnostics) {
|
|
|
1894
2371
|
};
|
|
1895
2372
|
}
|
|
1896
2373
|
|
|
2374
|
+
// src/ir/entity-route-injector.ts
|
|
2375
|
+
var SYNTHETIC_MODULE = "__entities";
|
|
2376
|
+
function toPascalCase(s) {
|
|
2377
|
+
return s.split("-").map((w) => w[0]?.toUpperCase() + w.slice(1)).join("");
|
|
2378
|
+
}
|
|
2379
|
+
function injectEntityRoutes(ir) {
|
|
2380
|
+
if (!ir.entities.length)
|
|
2381
|
+
return;
|
|
2382
|
+
const routes = [];
|
|
2383
|
+
for (const entity of ir.entities) {
|
|
2384
|
+
routes.push(...generateCrudRoutes(entity));
|
|
2385
|
+
routes.push(...generateActionRoutes(entity));
|
|
2386
|
+
}
|
|
2387
|
+
if (!routes.length)
|
|
2388
|
+
return;
|
|
2389
|
+
const router = {
|
|
2390
|
+
name: `${SYNTHETIC_MODULE}_router`,
|
|
2391
|
+
moduleName: SYNTHETIC_MODULE,
|
|
2392
|
+
prefix: "",
|
|
2393
|
+
inject: [],
|
|
2394
|
+
routes,
|
|
2395
|
+
sourceFile: "",
|
|
2396
|
+
sourceLine: 0,
|
|
2397
|
+
sourceColumn: 0
|
|
2398
|
+
};
|
|
2399
|
+
const module = {
|
|
2400
|
+
name: SYNTHETIC_MODULE,
|
|
2401
|
+
imports: [],
|
|
2402
|
+
services: [],
|
|
2403
|
+
routers: [router],
|
|
2404
|
+
exports: [],
|
|
2405
|
+
sourceFile: "",
|
|
2406
|
+
sourceLine: 0,
|
|
2407
|
+
sourceColumn: 0
|
|
2408
|
+
};
|
|
2409
|
+
ir.modules.push(module);
|
|
2410
|
+
}
|
|
2411
|
+
function generateCrudRoutes(entity) {
|
|
2412
|
+
const entityPascal = toPascalCase(entity.name);
|
|
2413
|
+
const basePath = `/${entity.name}`;
|
|
2414
|
+
const routes = [];
|
|
2415
|
+
const ops = [
|
|
2416
|
+
{ op: "list", method: "GET", path: basePath, idParam: false },
|
|
2417
|
+
{ op: "get", method: "GET", path: `${basePath}/:id`, idParam: true },
|
|
2418
|
+
{ op: "create", method: "POST", path: basePath, idParam: false },
|
|
2419
|
+
{ op: "update", method: "PATCH", path: `${basePath}/:id`, idParam: true },
|
|
2420
|
+
{ op: "delete", method: "DELETE", path: `${basePath}/:id`, idParam: true }
|
|
2421
|
+
];
|
|
2422
|
+
for (const { op, method, path } of ops) {
|
|
2423
|
+
const accessKind = entity.access[op];
|
|
2424
|
+
if (accessKind === "false")
|
|
2425
|
+
continue;
|
|
2426
|
+
const route = {
|
|
2427
|
+
method,
|
|
2428
|
+
path,
|
|
2429
|
+
fullPath: path,
|
|
2430
|
+
operationId: `${op}${entityPascal}`,
|
|
2431
|
+
middleware: [],
|
|
2432
|
+
tags: [entity.name],
|
|
2433
|
+
description: `${op} ${entity.name}`,
|
|
2434
|
+
...entity
|
|
2435
|
+
};
|
|
2436
|
+
if (entity.modelRef.schemaRefs.resolved) {
|
|
2437
|
+
if (op === "create") {
|
|
2438
|
+
route.body = entity.modelRef.schemaRefs.createInput;
|
|
2439
|
+
route.response = entity.modelRef.schemaRefs.response;
|
|
2440
|
+
} else if (op === "update") {
|
|
2441
|
+
route.body = entity.modelRef.schemaRefs.updateInput;
|
|
2442
|
+
route.response = entity.modelRef.schemaRefs.response;
|
|
2443
|
+
} else if (op === "list") {
|
|
2444
|
+
route.response = wrapInPaginatedEnvelope(entity.modelRef.schemaRefs.response);
|
|
2445
|
+
} else {
|
|
2446
|
+
route.response = entity.modelRef.schemaRefs.response;
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
routes.push(route);
|
|
2450
|
+
}
|
|
2451
|
+
return routes;
|
|
2452
|
+
}
|
|
2453
|
+
function wrapInPaginatedEnvelope(itemSchema) {
|
|
2454
|
+
if (!itemSchema)
|
|
2455
|
+
return;
|
|
2456
|
+
const itemJsonSchema = itemSchema.kind === "named" ? { $ref: `#/components/schemas/${itemSchema.schemaName}` } : itemSchema.jsonSchema ?? {};
|
|
2457
|
+
return {
|
|
2458
|
+
kind: "inline",
|
|
2459
|
+
sourceFile: itemSchema.sourceFile,
|
|
2460
|
+
jsonSchema: {
|
|
2461
|
+
type: "object",
|
|
2462
|
+
properties: {
|
|
2463
|
+
items: { type: "array", items: itemJsonSchema },
|
|
2464
|
+
total: { type: "number" },
|
|
2465
|
+
limit: { type: "number" },
|
|
2466
|
+
nextCursor: { type: ["string", "null"] },
|
|
2467
|
+
hasNextPage: { type: "boolean" }
|
|
2468
|
+
},
|
|
2469
|
+
required: ["items", "total", "limit", "nextCursor", "hasNextPage"]
|
|
2470
|
+
}
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
function generateActionRoutes(entity) {
|
|
2474
|
+
const entityPascal = toPascalCase(entity.name);
|
|
2475
|
+
return entity.actions.filter((action) => entity.access.custom[action.name] !== "false").map((action) => {
|
|
2476
|
+
const method = action.method;
|
|
2477
|
+
const path = action.path ? `/${entity.name}/${action.path}` : `/${entity.name}/:id/${action.name}`;
|
|
2478
|
+
const fullPath = path;
|
|
2479
|
+
return {
|
|
2480
|
+
method,
|
|
2481
|
+
path,
|
|
2482
|
+
fullPath,
|
|
2483
|
+
operationId: `${action.name}${entityPascal}`,
|
|
2484
|
+
params: action.params,
|
|
2485
|
+
query: action.query,
|
|
2486
|
+
headers: action.headers,
|
|
2487
|
+
body: action.body,
|
|
2488
|
+
response: action.response,
|
|
2489
|
+
middleware: [],
|
|
2490
|
+
tags: [entity.name],
|
|
2491
|
+
description: `${action.name} on ${entity.name}`,
|
|
2492
|
+
sourceFile: action.sourceFile,
|
|
2493
|
+
sourceLine: action.sourceLine,
|
|
2494
|
+
sourceColumn: action.sourceColumn
|
|
2495
|
+
};
|
|
2496
|
+
});
|
|
2497
|
+
}
|
|
2498
|
+
function detectRouteCollisions(ir) {
|
|
2499
|
+
const diagnostics = [];
|
|
2500
|
+
const seen = new Map;
|
|
2501
|
+
for (const mod of ir.modules) {
|
|
2502
|
+
if (mod.name === SYNTHETIC_MODULE)
|
|
2503
|
+
continue;
|
|
2504
|
+
for (const router of mod.routers) {
|
|
2505
|
+
for (const route of router.routes) {
|
|
2506
|
+
seen.set(route.operationId, route);
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
const entityModule = ir.modules.find((m) => m.name === SYNTHETIC_MODULE);
|
|
2511
|
+
if (entityModule) {
|
|
2512
|
+
for (const router of entityModule.routers) {
|
|
2513
|
+
for (const route of router.routes) {
|
|
2514
|
+
const existing = seen.get(route.operationId);
|
|
2515
|
+
if (existing) {
|
|
2516
|
+
diagnostics.push({
|
|
2517
|
+
code: "ENTITY_ROUTE_COLLISION",
|
|
2518
|
+
severity: "error",
|
|
2519
|
+
message: `Entity-generated operationId "${route.operationId}" collides with existing route at ${existing.sourceFile}:${existing.sourceLine}`,
|
|
2520
|
+
...route
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
seen.set(route.operationId, route);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
return diagnostics;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
1897
2530
|
// src/validators/completeness-validator.ts
|
|
1898
2531
|
var METHODS_WITHOUT_RESPONSE = new Set(["DELETE", "HEAD", "OPTIONS"]);
|
|
1899
2532
|
var RESERVED_CTX_KEYS = new Set([
|
|
@@ -2424,13 +3057,19 @@ class Compiler {
|
|
|
2424
3057
|
const moduleResult = await analyzers.module.analyze();
|
|
2425
3058
|
const middlewareResult = await analyzers.middleware.analyze();
|
|
2426
3059
|
const appResult = await analyzers.app.analyze();
|
|
3060
|
+
const entityResult = await analyzers.entity.analyze();
|
|
2427
3061
|
const depGraphResult = await analyzers.dependencyGraph.analyze();
|
|
2428
3062
|
ir.env = envResult.env;
|
|
2429
3063
|
ir.schemas = schemaResult.schemas;
|
|
2430
3064
|
ir.modules = moduleResult.modules;
|
|
2431
3065
|
ir.middleware = middlewareResult.middleware;
|
|
2432
3066
|
ir.app = appResult.app;
|
|
3067
|
+
ir.entities = entityResult.entities;
|
|
2433
3068
|
ir.dependencyGraph = depGraphResult.graph;
|
|
3069
|
+
ir.diagnostics.push(...analyzers.env.getDiagnostics(), ...analyzers.schema.getDiagnostics(), ...analyzers.middleware.getDiagnostics(), ...analyzers.module.getDiagnostics(), ...analyzers.app.getDiagnostics(), ...analyzers.entity.getDiagnostics(), ...analyzers.dependencyGraph.getDiagnostics());
|
|
3070
|
+
injectEntityRoutes(ir);
|
|
3071
|
+
const collisionDiags = detectRouteCollisions(ir);
|
|
3072
|
+
ir.diagnostics.push(...collisionDiags);
|
|
2434
3073
|
return enrichSchemasWithModuleNames(ir);
|
|
2435
3074
|
}
|
|
2436
3075
|
async validate(ir) {
|
|
@@ -2443,6 +3082,7 @@ class Compiler {
|
|
|
2443
3082
|
}
|
|
2444
3083
|
async generate(ir) {
|
|
2445
3084
|
const outputDir = this.config.compiler.outputDir;
|
|
3085
|
+
await mkdir(resolve(outputDir), { recursive: true });
|
|
2446
3086
|
await Promise.all(this.deps.generators.map((g) => g.generate(ir, outputDir)));
|
|
2447
3087
|
}
|
|
2448
3088
|
async compile() {
|
|
@@ -2469,6 +3109,7 @@ function createCompiler(config) {
|
|
|
2469
3109
|
middleware: new MiddlewareAnalyzer(project, resolved),
|
|
2470
3110
|
module: new ModuleAnalyzer(project, resolved),
|
|
2471
3111
|
app: new AppAnalyzer(project, resolved),
|
|
3112
|
+
entity: new EntityAnalyzer(project, resolved),
|
|
2472
3113
|
dependencyGraph: new DependencyGraphAnalyzer(project, resolved)
|
|
2473
3114
|
},
|
|
2474
3115
|
validators: [
|
|
@@ -2680,7 +3321,7 @@ async function* typecheckWatch(options = {}) {
|
|
|
2680
3321
|
const completionMarker = /Found \d+ error/;
|
|
2681
3322
|
let buffer = "";
|
|
2682
3323
|
const results = [];
|
|
2683
|
-
let
|
|
3324
|
+
let resolve2 = null;
|
|
2684
3325
|
let done = false;
|
|
2685
3326
|
const onData = (chunk) => {
|
|
2686
3327
|
buffer += chunk.toString();
|
|
@@ -2688,14 +3329,14 @@ async function* typecheckWatch(options = {}) {
|
|
|
2688
3329
|
const result = parseWatchBlock(buffer);
|
|
2689
3330
|
buffer = "";
|
|
2690
3331
|
results.push(result);
|
|
2691
|
-
|
|
3332
|
+
resolve2?.();
|
|
2692
3333
|
}
|
|
2693
3334
|
};
|
|
2694
3335
|
proc.stdout?.on("data", onData);
|
|
2695
3336
|
proc.stderr?.on("data", onData);
|
|
2696
3337
|
proc.on("close", () => {
|
|
2697
3338
|
done = true;
|
|
2698
|
-
|
|
3339
|
+
resolve2?.();
|
|
2699
3340
|
});
|
|
2700
3341
|
try {
|
|
2701
3342
|
while (!done || results.length > 0) {
|
|
@@ -2704,7 +3345,7 @@ async function* typecheckWatch(options = {}) {
|
|
|
2704
3345
|
yield next;
|
|
2705
3346
|
} else if (!done) {
|
|
2706
3347
|
await new Promise((r) => {
|
|
2707
|
-
|
|
3348
|
+
resolve2 = r;
|
|
2708
3349
|
});
|
|
2709
3350
|
}
|
|
2710
3351
|
}
|
|
@@ -2717,11 +3358,11 @@ async function typecheck(options = {}) {
|
|
|
2717
3358
|
if (options.tsconfigPath) {
|
|
2718
3359
|
args.push("--project", options.tsconfigPath);
|
|
2719
3360
|
}
|
|
2720
|
-
return new Promise((
|
|
3361
|
+
return new Promise((resolve2) => {
|
|
2721
3362
|
execFile("tsc", args, { cwd: process.cwd() }, (error, stdout, stderr) => {
|
|
2722
3363
|
const output = stdout + stderr;
|
|
2723
3364
|
const diagnostics = parseTscOutput(output);
|
|
2724
|
-
|
|
3365
|
+
resolve2({
|
|
2725
3366
|
success: !error,
|
|
2726
3367
|
diagnostics
|
|
2727
3368
|
});
|
|
@@ -2782,6 +3423,7 @@ export {
|
|
|
2782
3423
|
isSchemaFile,
|
|
2783
3424
|
isSchemaExpression,
|
|
2784
3425
|
isFromImport,
|
|
3426
|
+
injectEntityRoutes,
|
|
2785
3427
|
hasErrors,
|
|
2786
3428
|
getVariableNameForCall,
|
|
2787
3429
|
getStringValue,
|
|
@@ -2799,6 +3441,7 @@ export {
|
|
|
2799
3441
|
extractObjectLiteral,
|
|
2800
3442
|
extractMethodSignatures,
|
|
2801
3443
|
extractIdentifierNames,
|
|
3444
|
+
detectRouteCollisions,
|
|
2802
3445
|
defineConfig,
|
|
2803
3446
|
createSchemaExecutor,
|
|
2804
3447
|
createNamedSchemaRef,
|
|
@@ -2828,6 +3471,7 @@ export {
|
|
|
2828
3471
|
ManifestGenerator,
|
|
2829
3472
|
IncrementalCompiler,
|
|
2830
3473
|
EnvAnalyzer,
|
|
3474
|
+
EntityAnalyzer,
|
|
2831
3475
|
DependencyGraphAnalyzer,
|
|
2832
3476
|
CompletenessValidator,
|
|
2833
3477
|
Compiler,
|