diff --git a/.github/workflows/scaffold-smoke.yml b/.github/workflows/scaffold-smoke.yml index cc142e5..8c9beef 100644 --- a/.github/workflows/scaffold-smoke.yml +++ b/.github/workflows/scaffold-smoke.yml @@ -24,7 +24,14 @@ jobs: fail-fast: false matrix: language: [typescript, python, go] - name: scaffold-and-compile (${{ matrix.language }}) + # Empty default so the include below adds a 4th leg instead of + # attaching the fixture to the existing typescript leg. + fixture: [""] + include: + # Multi-FK prod schema that once failed the in-browser build. + - language: typescript + fixture: test/fixtures/inventory-multi-fk.apsorc + name: scaffold-and-compile (${{ matrix.language }}${{ matrix.fixture && ' + fixture' || '' }}) steps: - uses: actions/checkout@v4 with: @@ -50,4 +57,6 @@ jobs: python-version: "3.12" - name: Scaffold and compile + env: + APSORC_FIXTURE: ${{ matrix.fixture }} run: bash scripts/scaffold-smoke.sh "${{ matrix.language }}" diff --git a/scripts/scaffold-smoke.sh b/scripts/scaffold-smoke.sh index 6dd2a0e..d7be914 100755 --- a/scripts/scaffold-smoke.sh +++ b/scripts/scaffold-smoke.sh @@ -13,6 +13,7 @@ # # Exit non-zero on the first failure so CI surfaces a broken scaffold. # Run locally: bash scripts/scaffold-smoke.sh [typescript|python|go] +# Optional: APSORC_FIXTURE=test/fixtures/.apsorc to generate from a fixture. set -euo pipefail LANGUAGE="${1:-typescript}" @@ -31,7 +32,17 @@ cd "$WORKDIR" "$CLI_ROOT/bin/run" init --name smoke-app --language "$LANGUAGE" --skip-platform cd smoke-app -if [ -f .apsorc ]; then +if [ -n "${APSORC_FIXTURE:-}" ]; then + # A real-world schema (e.g. test/fixtures/inventory-multi-fk.apsorc: 9 + # entities, 13 relationships, several FKs to the same entity) instead of + # the one-entity seed below. + echo "==> Using fixture schema $APSORC_FIXTURE" + cp "$CLI_ROOT/$APSORC_FIXTURE" .apsorc + "$CLI_ROOT/bin/run" generate --language "$LANGUAGE" || { + echo "FAIL: apso generate errored on $APSORC_FIXTURE" + exit 1 + } +elif [ -f .apsorc ]; then # Templates ship with "entities": [] and every generator rejects an empty # entity list, so seed one small entity. This also turns the run into a real # codegen check: the generated files must compile below. diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 2b902f2..8263b24 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -13,6 +13,7 @@ import { isInteractive, missingFlag } from "../lib/utils/interactive"; import { performance } from "perf_hooks"; import { createFile } from "../lib/utils/file-system"; import { installCoAuthorHook } from "../lib/utils/git-hooks"; +import { ensureCrudDeps } from "../lib/utils/crud-deps"; export default class Generate extends BaseCommand { static description = "Generate code from .apsorc schema"; @@ -312,6 +313,18 @@ export default class Generate extends BaseCommand { console.log("[apso] Skipping formatting (no files were generated)"); } + // Generated TS code imports @apso/crud*. Projects from the v1 template + // (@nestjsx/crud) don't declare those, and every entity then fails to + // compile with TS2307. Declare them so the next install picks them up. + if (language === "typescript" && generatedFileCount > 0) { + const added = ensureCrudDeps(process.cwd()); + if (added.length > 0) { + console.log( + `[apso] Added ${added.join(", ")} to package.json (the generated code imports them). Run npm install before building.` + ); + } + } + const totalBuildTime = performance.now() - totalBuildStart; console.log( `[apso] Finished building all entities in ${totalBuildTime.toFixed(2)} ms` diff --git a/src/lib/utils/crud-deps.ts b/src/lib/utils/crud-deps.ts new file mode 100644 index 0000000..ebb7f27 --- /dev/null +++ b/src/lib/utils/crud-deps.ts @@ -0,0 +1,44 @@ +import * as fs from "fs"; +import * as path from "path"; + +/** + * Runtime packages the generated TypeScript code imports (entities, services, + * controllers, guards). Versions match the pinned service-template-ts tag + * (see TEMPLATE_REFS in ./template.ts); bump both together. + */ +export const APSO_CRUD_DEPS: Record = { + "@apso/crud": "^1.0.1", + "@apso/crud-core": "^1.0.1", + "@apso/crud-request": "^1.0.1", + "@apso/crud-typeorm": "^1.0.1", +}; + +/** + * Add any missing @apso/crud runtime packages to the project's package.json. + * + * Projects created from the v1 template (@nestjsx/crud) don't declare them, so + * freshly generated code fails `nest build` with TS2307 "Cannot find module + * '@apso/crud'" on every entity. Existing @nestjsx deps are left alone because + * hand-written code may still import them. + * + * @param {string} projectDir - Directory holding the project's package.json. + * @returns {string[]} Names added (empty when nothing changed or no package.json). + */ +export function ensureCrudDeps(projectDir: string): string[] { + const pkgPath = path.join(projectDir, "package.json"); + if (!fs.existsSync(pkgPath)) return []; + + const raw = fs.readFileSync(pkgPath, "utf8"); + const pkg = JSON.parse(raw); + const declared = { ...pkg.devDependencies, ...pkg.dependencies }; + const missing = Object.keys(APSO_CRUD_DEPS).filter((name) => !declared[name]); + if (missing.length === 0) return []; + + pkg.dependencies = pkg.dependencies ?? {}; + for (const name of missing) pkg.dependencies[name] = APSO_CRUD_DEPS[name]; + pkg.dependencies = Object.fromEntries( + Object.entries(pkg.dependencies).sort(([a], [b]) => a.localeCompare(b)) + ); + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + (raw.endsWith("\n") ? "\n" : "")); + return missing; +} diff --git a/test/fixtures/inventory-multi-fk.apsorc b/test/fixtures/inventory-multi-fk.apsorc new file mode 100644 index 0000000..5ff00dd --- /dev/null +++ b/test/fixtures/inventory-multi-fk.apsorc @@ -0,0 +1,799 @@ +{ + "version": 2, + "rootFolder": "src", + "apiType": "Rest", + "entities": [ + { + "name": "Product", + "fields": [ + { + "name": "name", + "type": "varchar", + "nullable": false + }, + { + "name": "sku", + "type": "varchar", + "isIndex": true, + "isUnique": true, + "nullable": false + }, + { + "name": "barcode", + "type": "varchar", + "isIndex": true, + "isUnique": true, + "nullable": true + }, + { + "name": "description", + "type": "text", + "nullable": true + }, + { + "name": "category", + "type": "varchar", + "isIndex": true, + "nullable": true + }, + { + "name": "unit_of_measure", + "type": "varchar", + "nullable": false + }, + { + "name": "reorder_point", + "type": "integer", + "default": 0, + "nullable": false + }, + { + "name": "reorder_quantity", + "type": "integer", + "default": 0, + "nullable": false + } + ], + "indexes": [ + { + "fields": [ + "sku" + ], + "unique": true + }, + { + "fields": [ + "barcode" + ], + "unique": true + }, + { + "fields": [ + "category" + ], + "unique": false + }, + { + "fields": [ + "name" + ], + "unique": false + } + ], + "uniques": [ + { + "name": "unique_product_sku", + "fields": [ + "sku" + ] + }, + { + "name": "unique_product_barcode", + "fields": [ + "barcode" + ] + } + ], + "created_at": true, + "updated_at": true + }, + { + "name": "Warehouse", + "fields": [ + { + "name": "name", + "type": "varchar", + "isIndex": true, + "nullable": false + }, + { + "name": "address", + "type": "varchar", + "nullable": false + }, + { + "name": "city", + "type": "varchar", + "isIndex": true, + "nullable": false + }, + { + "name": "state", + "type": "varchar", + "nullable": false + }, + { + "name": "postal_code", + "type": "varchar", + "nullable": false + }, + { + "name": "country", + "type": "varchar", + "nullable": false + }, + { + "name": "capacity", + "type": "bigint", + "nullable": false + }, + { + "name": "manager_name", + "type": "varchar", + "nullable": true + }, + { + "name": "manager_email", + "type": "varchar", + "isIndex": true, + "isUnique": true, + "nullable": true + } + ], + "indexes": [ + { + "fields": [ + "name" + ], + "unique": false + }, + { + "fields": [ + "city" + ], + "unique": false + }, + { + "fields": [ + "manager_email" + ], + "unique": false + }, + { + "fields": [ + "manager_email" + ], + "unique": true + } + ], + "uniques": [ + { + "name": "unique_warehouse_manager_email", + "fields": [ + "manager_email" + ] + } + ], + "created_at": true, + "updated_at": true + }, + { + "name": "Supplier", + "fields": [ + { + "name": "company_name", + "type": "varchar", + "isIndex": true, + "nullable": false + }, + { + "name": "contact_person", + "type": "varchar", + "nullable": false + }, + { + "name": "email", + "type": "varchar", + "isIndex": true, + "isUnique": true, + "nullable": false + }, + { + "name": "phone", + "type": "varchar", + "nullable": true + }, + { + "name": "payment_terms", + "type": "varchar", + "nullable": false + }, + { + "name": "lead_time_days", + "type": "integer", + "default": 0, + "nullable": false + } + ], + "indexes": [ + { + "fields": [ + "company_name" + ], + "unique": false + }, + { + "fields": [ + "email" + ], + "unique": true + } + ], + "uniques": [ + { + "name": "unique_supplier_email", + "fields": [ + "email" + ] + } + ], + "created_at": true, + "updated_at": true + }, + { + "name": "InventoryLevel", + "fields": [ + { + "name": "quantity_on_hand", + "type": "integer", + "default": 0, + "nullable": false + }, + { + "name": "quantity_reserved", + "type": "integer", + "default": 0, + "nullable": false + }, + { + "name": "quantity_available", + "type": "integer", + "default": 0, + "nullable": false + }, + { + "name": "last_counted_date", + "type": "timestamptz", + "nullable": true + }, + { + "name": "recount_required", + "type": "boolean", + "default": false, + "nullable": false + } + ], + "indexes": [ + { + "fields": [ + "quantity_available" + ], + "unique": false + }, + { + "fields": [ + "last_counted_date" + ], + "unique": false + } + ], + "uniques": [], + "created_at": true, + "updated_at": true + }, + { + "name": "PurchaseOrder", + "fields": [ + { + "name": "order_number", + "type": "varchar", + "isIndex": true, + "isUnique": true, + "nullable": false + }, + { + "name": "order_date", + "type": "timestamptz", + "isIndex": true, + "nullable": false + }, + { + "name": "expected_delivery_date", + "type": "timestamptz", + "isIndex": true, + "nullable": false + }, + { + "name": "status", + "type": "enum", + "values": [ + "draft", + "sent", + "partial", + "received", + "cancelled" + ], + "isIndex": true, + "nullable": false + }, + { + "name": "total_amount", + "type": "numeric", + "nullable": false + }, + { + "name": "notes", + "type": "text", + "nullable": true + } + ], + "indexes": [ + { + "fields": [ + "order_number" + ], + "unique": true + }, + { + "fields": [ + "order_date" + ], + "unique": false + }, + { + "fields": [ + "expected_delivery_date" + ], + "unique": false + }, + { + "fields": [ + "status" + ], + "unique": false + } + ], + "uniques": [ + { + "name": "unique_purchase_order_number", + "fields": [ + "order_number" + ] + } + ], + "created_at": true, + "updated_at": true + }, + { + "name": "StockMovement", + "fields": [ + { + "name": "movement_type", + "type": "enum", + "values": [ + "received", + "sold", + "transferred", + "adjusted", + "damaged" + ], + "isIndex": true, + "nullable": false + }, + { + "name": "quantity", + "type": "integer", + "nullable": false + }, + { + "name": "movement_date", + "type": "timestamptz", + "isIndex": true, + "nullable": false + }, + { + "name": "reference_document_id", + "type": "varchar", + "isIndex": true, + "nullable": true + }, + { + "name": "reference_document_type", + "type": "enum", + "values": [ + "purchase_order", + "stock_transfer", + "inventory_count", + "manual" + ], + "isIndex": true, + "nullable": false + }, + { + "name": "notes", + "type": "text", + "nullable": true + } + ], + "indexes": [ + { + "fields": [ + "movement_type" + ], + "unique": false + }, + { + "fields": [ + "movement_date" + ], + "unique": false + }, + { + "fields": [ + "reference_document_id" + ], + "unique": false + }, + { + "fields": [ + "reference_document_type" + ], + "unique": false + }, + { + "fields": [ + "movement_type", + "movement_date" + ], + "unique": false + } + ], + "uniques": [], + "created_at": true, + "updated_at": true + }, + { + "name": "StockTransfer", + "fields": [ + { + "name": "transfer_number", + "type": "varchar", + "isIndex": true, + "isUnique": true, + "nullable": false + }, + { + "name": "transfer_date", + "type": "timestamptz", + "isIndex": true, + "nullable": false + }, + { + "name": "expected_arrival_date", + "type": "timestamptz", + "isIndex": true, + "nullable": false + }, + { + "name": "status", + "type": "enum", + "values": [ + "pending", + "in_transit", + "received", + "cancelled" + ], + "isIndex": true, + "nullable": false + }, + { + "name": "shipping_method", + "type": "varchar", + "nullable": false + }, + { + "name": "tracking_number", + "type": "varchar", + "isIndex": true, + "nullable": true + }, + { + "name": "notes", + "type": "text", + "nullable": true + } + ], + "indexes": [ + { + "fields": [ + "transfer_number" + ], + "unique": true + }, + { + "fields": [ + "transfer_date" + ], + "unique": false + }, + { + "fields": [ + "expected_arrival_date" + ], + "unique": false + }, + { + "fields": [ + "status" + ], + "unique": false + }, + { + "fields": [ + "tracking_number" + ], + "unique": false + } + ], + "uniques": [ + { + "name": "unique_stock_transfer_number", + "fields": [ + "transfer_number" + ] + } + ], + "created_at": true, + "updated_at": true + }, + { + "name": "InventoryCount", + "fields": [ + { + "name": "count_date", + "type": "timestamptz", + "isIndex": true, + "nullable": false + }, + { + "name": "count_status", + "type": "enum", + "values": [ + "pending", + "in_progress", + "completed", + "approved" + ], + "isIndex": true, + "nullable": false + }, + { + "name": "discrepancy_count", + "type": "integer", + "default": 0, + "nullable": false + }, + { + "name": "adjustment_approved", + "type": "boolean", + "default": false, + "nullable": false + }, + { + "name": "approval_date", + "type": "timestamptz", + "nullable": true + }, + { + "name": "approved_by", + "type": "varchar", + "nullable": true + } + ], + "indexes": [ + { + "fields": [ + "count_date" + ], + "unique": false + }, + { + "fields": [ + "count_status" + ], + "unique": false + }, + { + "fields": [ + "approval_date" + ], + "unique": false + } + ], + "uniques": [], + "created_at": true, + "updated_at": true + }, + { + "name": "Alert", + "fields": [ + { + "name": "alert_type", + "type": "enum", + "values": [ + "low_stock", + "reorder_trigger", + "overstock", + "expiration" + ], + "isIndex": true, + "nullable": false + }, + { + "name": "severity", + "type": "enum", + "values": [ + "info", + "warning", + "critical" + ], + "isIndex": true, + "nullable": false + }, + { + "name": "triggered_date", + "type": "timestamptz", + "isIndex": true, + "nullable": false + }, + { + "name": "resolved_date", + "type": "timestamptz", + "isIndex": true, + "nullable": true + }, + { + "name": "is_resolved", + "type": "boolean", + "values": [], + "default": false, + "nullable": false + }, + { + "name": "message", + "type": "text", + "nullable": false + } + ], + "indexes": [ + { + "fields": [ + "alert_type" + ], + "unique": false + }, + { + "fields": [ + "severity" + ], + "unique": false + }, + { + "fields": [ + "triggered_date" + ], + "unique": false + }, + { + "fields": [ + "resolved_date" + ], + "unique": false + }, + { + "fields": [ + "is_resolved" + ], + "unique": false + }, + { + "fields": [ + "alert_type", + "severity" + ], + "unique": false + } + ], + "uniques": [], + "created_at": true, + "updated_at": true + } + ], + "relationships": [ + { + "to": "Product", + "from": "InventoryLevel", + "type": "ManyToOne" + }, + { + "to": "Warehouse", + "from": "InventoryLevel", + "type": "ManyToOne" + }, + { + "to": "Supplier", + "from": "PurchaseOrder", + "type": "ManyToOne" + }, + { + "to": "Product", + "from": "StockMovement", + "type": "ManyToOne" + }, + { + "to": "Warehouse", + "from": "StockMovement", + "type": "ManyToOne" + }, + { + "to": "Warehouse", + "from": "StockTransfer", + "type": "ManyToOne" + }, + { + "to": "Warehouse", + "from": "InventoryCount", + "type": "ManyToOne" + }, + { + "to": "Product", + "from": "Alert", + "type": "ManyToOne" + }, + { + "to": "Warehouse", + "from": "Alert", + "type": "ManyToOne" + }, + { + "to": "PurchaseOrder", + "from": "StockMovement", + "type": "ManyToOne" + }, + { + "to": "StockTransfer", + "from": "StockMovement", + "type": "ManyToOne" + }, + { + "to": "Product", + "from": "StockTransfer", + "type": "ManyToOne" + }, + { + "from": "StockMovement", + "to": "InventoryCount", + "type": "ManyToOne" + } + ] +} \ No newline at end of file diff --git a/test/lib/utils/crud-deps.test.ts b/test/lib/utils/crud-deps.test.ts new file mode 100644 index 0000000..be09f7f --- /dev/null +++ b/test/lib/utils/crud-deps.test.ts @@ -0,0 +1,94 @@ +import { expect, describe, test } from "@jest/globals"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { APSO_CRUD_DEPS, ensureCrudDeps } from "../../../src/lib/utils/crud-deps"; +import { TypeScriptGenerator } from "../../../src/lib/generators/typescript"; +import { parseApsorcV2 } from "../../../src/lib/apsorc-parser"; + +function projectWith(pkg: object): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-crud-deps-")); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n"); + return dir; +} +const readPkg = (dir: string) => + // eslint-disable-next-line unicorn/prefer-json-parse-buffer -- JSON.parse's TS type requires a string + JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")); + +describe("ensureCrudDeps", () => { + test("adds @apso/crud* to a v1 (@nestjsx/crud) project and keeps @nestjsx", () => { + const dir = projectWith({ + dependencies: { "@nestjs/core": "^10.0.0", "@nestjsx/crud": "4.5.0" }, + }); + + expect(ensureCrudDeps(dir)).toEqual(Object.keys(APSO_CRUD_DEPS)); + + const deps = readPkg(dir).dependencies; + for (const [name, version] of Object.entries(APSO_CRUD_DEPS)) { + expect(deps[name]).toBe(version); + } + expect(deps["@nestjsx/crud"]).toBe("4.5.0"); + expect(fs.readFileSync(path.join(dir, "package.json"), "utf8").endsWith("\n")).toBe(true); + }); + + test("leaves a v2 project untouched", () => { + const dir = projectWith({ dependencies: { ...APSO_CRUD_DEPS, "@apso/crud": "^1.2.0" } }); + const before = fs.readFileSync(path.join(dir, "package.json"), "utf8"); + + expect(ensureCrudDeps(dir)).toEqual([]); + expect(fs.readFileSync(path.join(dir, "package.json"), "utf8")).toBe(before); + }); + + test("is a no-op without a package.json", () => { + expect(ensureCrudDeps(fs.mkdtempSync(path.join(os.tmpdir(), "apso-crud-deps-")))).toEqual([]); + }); +}); + +// Drift guard: every @apso/* package the generated code imports must be one +// ensureCrudDeps declares. Uses the prod "Inventory Management" schema that +// failed the in-browser build (9 entities, 13 relationships, 5 FKs on one +// entity) so the import surface is broad. +describe("generated TypeScript imports", () => { + test("only import @apso packages listed in APSO_CRUD_DEPS", async () => { + const rc = JSON.parse( + // eslint-disable-next-line unicorn/prefer-json-parse-buffer -- JSON.parse's TS type requires a string + fs.readFileSync(path.join(__dirname, "../../fixtures/inventory-multi-fk.apsorc"), "utf8") + ); + const { entities, relationshipMap } = parseApsorcV2(rc); + const generator = new TypeScriptGenerator({ + rootFolder: "src", + entities, + relationshipMap, + apiType: "rest", + language: "typescript", + }); + + const files = []; + /* eslint-disable no-await-in-loop -- generated in order, like generate.ts */ + for (const entity of entities) { + const args = { + entity, + relationships: relationshipMap[entity.name] || [], + allEntities: entities, + apiType: "rest", + relationshipMap, + }; + files.push( + ...(await generator.generateEntity(args)), + ...(await generator.generateDto(args)), + ...(await generator.generateService(args)), + ...(await generator.generateController(args)), + ...(await generator.generateModule({ ...args, includeController: true })) + ); + } + /* eslint-enable no-await-in-loop */ + files.push(...(await generator.generateGuards(entities))); + + const imported = new Set(); + for (const f of files) { + for (const m of f.content.matchAll(/from ["'](@apso\/[\w-]+)/g)) imported.add(m[1]); + } + expect(imported.size).toBeGreaterThan(0); + for (const name of imported) expect(Object.keys(APSO_CRUD_DEPS)).toContain(name); + }); +});