@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/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 resolve = null;
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
- resolve?.();
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
- resolve?.();
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
- resolve = r;
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((resolve) => {
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
- resolve({
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,