diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0428e63 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +# Keep the build context small and secret-free. +**/node_modules +**/dist +**/build +**/.turbo +**/*.tsbuildinfo +**/coverage +.git +.github +**/.env +**/.env.* +!**/.env.example +neo4j_data +**/*.log +assets +docs +.idea +.vscode +.DS_Store diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f15441a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f50a8f9 --- /dev/null +++ b/.env.example @@ -0,0 +1,56 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Solarch OSS self-host configuration. +# +# Easiest: run ./install.sh (Linux/macOS) or ./install.ps1 (Windows) — an interactive +# wizard that fills this file for you. Or copy this to `.env`, edit, then: +# docker compose up --build → http://localhost:3000 +# +# Only Neo4j + one AI provider are required. +# ───────────────────────────────────────────────────────────────────────────── + +PUBLIC_URL=http://localhost:3000 +PORT_PUBLIC=3000 + +# ── Exposure / network (security) ──────────────────────────────────────────── +# Local (default): only this machine can reach the app. +BIND_ADDRESS=127.0.0.1 +# LAN / VPS: set BIND_ADDRESS=0.0.0.0 AND enable basic auth below (install.sh option 2). +# SOLARCH_BASIC_AUTH_USER= +# SOLARCH_BASIC_AUTH_HASH= # bcrypt from: caddy hash-password --plaintext 'your-password' + +# Rate limits (server-side; per IP by default in OSS mode) +# THROTTLE_BY=ip +# THROTTLE_LIMIT=60 +# THROTTLE_TTL_MS=60000 +# AI_THROTTLE_LIMIT=20 +# CODEGEN_FILL_THROTTLE_LIMIT=10 + +# ── Database (Neo4j) ───────────────────────────────────────────────────────── +NEO4J_PASSWORD=change_me_please + +# ── Local owner identity (no login screen) ─────────────────────────────────── +LOCAL_USER_ID=local_owner + +# ── AI provider — pick ONE and set its key ─────────────────────────────────── +# Provider ids: openai | anthropic | google | deepseek | mistral | groq | +# openrouter | ollama | bedrock | openai-compatible +LLM_GENERATION_PROVIDER=openai +LLM_CHAT_PROVIDER=openai +# LLM_MODEL=gpt-4o + +OPENAI_API_KEY=sk-xxxxxxxx +# ANTHROPIC_API_KEY=sk-ant-... +# GOOGLE_API_KEY=... +# DEEPSEEK_API_KEY=... +# MISTRAL_API_KEY=... +# GROQ_API_KEY=... +# OPENROUTER_API_KEY=... +# OLLAMA_BASE_URL=http://host.docker.internal:11434 +# BEDROCK_API_KEY= +# BEDROCK_BASE_URL= +# LLM_API_KEY= +# LLM_BASE_URL= + +# ── Embeddings (GraphRAG) — local by default ──────────────────────────────── +# EMBED_PROVIDER=local +# EMBED_MODEL=Xenova/paraphrase-multilingual-MiniLM-L12-v2 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..78ed5a8 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default owners for everything in the repo. +* @fatalerrorist diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..35f0d0f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ["https://solarch.dev"] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..1b5a960 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,46 @@ +name: Bug report +description: Something is broken or behaves unexpectedly. +labels: ["bug"] +body: + - type: markdown + attributes: + value: Thanks for the report. Please include enough detail to reproduce. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug, plus what you expected instead. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + placeholder: | + 1. Go to ... + 2. Click ... + 3. See error + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - web (canvas / UI) + - server (API / Neo4j) + - codegen / AI + - self-host (docker) + - other + validations: + required: true + - type: textarea + id: env + attributes: + label: Environment + description: Hosted app or self-host? Browser, OS, Node version, commit if known. + - type: textarea + id: logs + attributes: + label: Logs / screenshots + description: Console output, server logs, or a screen recording help a lot. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..aeb6fe8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Discussions + url: https://github.com/solarch-dev/solarch/discussions + about: Questions, ideas, design feedback, and architecture discussion. + - name: Try Solarch + url: https://app.solarch.dev + about: The hosted app — no install required. + - name: Commercial licensing + url: mailto:info@solidea.tech + about: Commercial use inquiries (PolyForm Noncommercial covers non-commercial use). diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..bb2d29f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,20 @@ +name: Feature request +description: Suggest an idea or improvement. +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: Describe the use case or pain point, not just the solution. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What you'd like to happen. Sketches/mockups welcome. + - type: textarea + id: alternatives + attributes: + label: Alternatives considered diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..989d488 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ + + +## What & why + + + +## Changes + +- + +## Checks + +- [ ] `pnpm build` passes (web + server) +- [ ] `pnpm --filter @solarch/server test:unit` passes (if the server changed) +- [ ] Conventional commit subject, no emojis +- [ ] No secrets or `.env` files committed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6d1fb5e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10.0.0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: OSS grep gate + run: | + set -euo pipefail + if rg -i 'clerk|polar|posthog|ClerkAuthGuard|BILLING_ENABLED|isLocalAuth|Henüz lisanslanmadı|misafir bileti' apps docs deploy --glob '!**/.github/**'; then + echo "OSS grep gate failed — SaaS remnants found" + exit 1 + fi + if rg '[ğüşıöçĞÜŞİÖÇ]' apps/server/src apps/server/test apps/server/deploy apps/server/README.md apps/server/scripts --glob '!**/.tr-en-cache.json'; then + echo "Turkish characters found under apps/server" + exit 1 + fi + + - name: Build server + working-directory: apps/server + env: + NEO4J_URI: bolt://localhost:7687 + NEO4J_USER: neo4j + NEO4J_PASSWORD: test + LLM_GENERATION_PROVIDER: openai + LLM_CHAT_PROVIDER: openai + OPENAI_API_KEY: test + run: pnpm build + + - name: Build web + run: pnpm build:web + + - name: Server unit tests + working-directory: apps/server + env: + NEO4J_URI: bolt://localhost:7687 + NEO4J_USER: neo4j + NEO4J_PASSWORD: test + LLM_GENERATION_PROVIDER: openai + LLM_CHAT_PROVIDER: openai + OPENAI_API_KEY: test + run: pnpm test:unit + + e2e: + runs-on: ubuntu-latest + needs: verify + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10.0.0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Server e2e tests + working-directory: apps/server + env: + NEO4J_URI: bolt://localhost:7687 + NEO4J_USER: neo4j + NEO4J_PASSWORD: test + LLM_GENERATION_PROVIDER: openai + LLM_CHAT_PROVIDER: openai + OPENAI_API_KEY: test + PORT: 4444 + CORS_ORIGIN: http://localhost:3000 + NODE_ENV: test + run: pnpm test:e2e diff --git a/.gitignore b/.gitignore index ae3897d..d07d0bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,34 @@ +# Dependencies node_modules/ +.pnpm-store/ + +# Build output dist/ +build/ +*.tsbuildinfo + +# Turborepo +.turbo/ + +# Environment (keep .env.example tracked) .env -.env.local +.env.* +!.env.example + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Test / coverage +coverage/ +.nyc_output/ + +# Neo4j local data +neo4j_data/ + +# Editor / OS +.idea/ .DS_Store +Thumbs.db +*.swp diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..1af656d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,42 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a +harassment-free experience for everyone, regardless of age, body size, visible or invisible +disability, ethnicity, sex characteristics, gender identity and expression, level of +experience, education, socio-economic status, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, +inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best for the overall community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the +maintainers at **[info@solidea.tech](mailto:info@solidea.tech)**. All complaints will be +reviewed and investigated promptly and fairly. Maintainers are obligated to respect the +privacy and security of the reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66def37..51ac7e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,18 +1,43 @@ # Contributing to Solarch -Thanks for the interest. Solarch is in active early-stage development — the API surface, node families, and edge semantics still move week to week — but feedback is very welcome. +Thanks for the interest. This repository is the **OSS self-host monorepo** — backend, canvas UI, +and Docker bundle. The API surface, node families, and edge semantics still move week to week, but +feedback and focused PRs are very welcome. ## Ways to help -- **File an issue.** Bugs, regressions, surprising UX, accessibility gaps. Reproduction steps + screenshot/gif when possible. Try it at [app.solarch.dev](https://app.solarch.dev). -- **Open a discussion.** Feature requests, design feedback, architecture questions, philosophical disagreement with the *Surgical AI* thesis — all fair game. -- **Improve the docs.** Small, focused PRs to this repository's README and docs are easy to review and always appreciated. +- **File an issue.** Bugs, regressions, surprising UX, accessibility gaps. Reproduction steps + + screenshot/gif when possible. +- **Open a discussion.** Feature requests, design feedback, architecture questions. +- **Improve the docs.** Small, focused PRs to `README.md` and `docs/` are easy to review. -## Source & self-hosting +## Local development -The hosted app at [app.solarch.dev](https://app.solarch.dev) is the fastest way to use Solarch today. +```bash +git clone https://github.com/solarch-dev/solarch.git +cd solarch +pnpm install +cp .env.example .env # Neo4j password + LLM provider + API key -This repository currently hosts the project overview and documentation. A **unified, one-command self-host bundle** — the whole stack behind a single `docker compose up` — is **coming soon**; watch / star to follow along. The application source is maintained separately and will land here as that bundle ships. +# Terminal 1 +pnpm dev:server # http://localhost:4000/api/v1 + +# Terminal 2 +pnpm dev:web # http://localhost:5173 +``` + +Neo4j for dev: `pnpm --filter @solarch/server neo4j:up && pnpm --filter @solarch/server neo4j:migrate` + +Full details: [docs/development.md](docs/development.md). + +## Self-host smoke test + +```bash +./install.sh +docker compose up --build +``` + +See [docs/getting-started.md](docs/getting-started.md). ## Commit style @@ -20,12 +45,14 @@ Conventional commits with short, focused subjects: ``` feat(canvas): add elbow edge mode -fix(refine): orphan DTO repair edge inference -docs: clarify self-hosting steps +fix(codegen): regen when graph revision conflicts +docs: clarify self-hosting basic auth ``` -No emojis in commit messages or docs. +No emojis in commit messages. ## License -By contributing, you agree your contributions are licensed under the [PolyForm Noncommercial License 1.0.0](./LICENSE) — same as the rest of the project. For commercial licensing inquiries, contact [info@solidea.tech](mailto:info@solidea.tech). +By contributing, you agree your contributions are licensed under the +[PolyForm Noncommercial License 1.0.0](./LICENSE). For commercial licensing inquiries, contact +[info@solidea.tech](mailto:info@solidea.tech). diff --git a/README.md b/README.md index baa9b52..098bda3 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,18 @@ [![Try it](https://img.shields.io/badge/Try%20it-app.solarch.dev-ff6b1a?style=flat-square)](https://app.solarch.dev) [![Website](https://img.shields.io/badge/Website-solarch.dev-0f0f0e?style=flat-square)](https://solarch.dev) +[![Self-Host](https://img.shields.io/badge/Self--Host-docker%20compose-666?style=flat-square)](docs/getting-started.md) [![License](https://img.shields.io/badge/License-PolyForm%20NC%201.0-orange.svg?style=flat-square)](./LICENSE) [![GitHub Stars](https://img.shields.io/github/stars/solarch-dev/solarch?style=flat-square)](https://github.com/solarch-dev/solarch/stargazers) [![Last Commit](https://img.shields.io/github/last-commit/solarch-dev/solarch?style=flat-square)](https://github.com/solarch-dev/solarch/commits/main) -[![Status](https://img.shields.io/badge/status-early%20access-ff6b1a?style=flat-square)](https://app.solarch.dev)
-### ▶   Try it live — no install — at **[app.solarch.dev](https://app.solarch.dev)**   ·   Learn more at **[solarch.dev](https://solarch.dev)** +### ▶   Try it live — **[app.solarch.dev](https://app.solarch.dev)**   ·   Learn more at **[solarch.dev](https://solarch.dev)**   ·   Or [**self-host**](#-self-hosting) this repo + +
+ +*No install, no Docker — open the app and start drawing. Self-host below if you want your own box.*
@@ -27,7 +31,7 @@
-[**Why Solarch?**](#-why-solarch)   •   [**Gallery**](#-gallery)   •   [**Features**](#-features)   •   [**Philosophy**](#-the-philosophy)   •   [**Self-Hosting**](#-self-hosting)   •   [**Get Involved**](#-get-involved) +[**Why Solarch?**](#-why-solarch)   •   [**Gallery**](#-gallery)   •   [**Features**](#-features)   •   [**Philosophy**](#-the-philosophy)   •   [**Try it**](#-try-it)   •   [**Self-Hosting**](#-self-hosting)   •   [**Docs**](#-documentation)   •   [**Get Involved**](#-get-involved) @@ -45,6 +49,8 @@ It generates **architecture first** — grounded in a library of canonical patte * **AI Architect grounded in GraphRAG:** an agentic LLM pipeline pulls from a vector-indexed pattern library. It never starts from a blank context, never invents an API surface. * **Rules Engine that refuses to lie:** 32 whitelist rules, 7 anti-patterns, 3 conditional checks. Frontends can't talk to tables. Controllers can't reach repositories. Period. * **Self-correcting loop:** Rules rejection feeds back into the agent; the AI revises and tries again until the graph is clean — or never commits. +* **Diagram → NestJS code:** deterministic codegen scaffold from the graph, plus optional Surgical AI for method bodies. +* **Four surfaces, one project:** Canvas, Code, API, and Docs — switch from the top bar without losing context. * **Live Instruct Mode:** Switch modes and chat with your design. Every answer cites the exact nodes; chips focus the canvas in real time. * **Single-home + reference tabs:** Each node lives in one tab. Other tabs *import* it as a reference, not a copy. One source of truth, multiple views. * **Type-safe from DB to button:** Zod schemas at the backend, OpenAPI in the middle, `openapi-fetch` on the frontend. The API contract is a compile-time check. @@ -115,6 +121,7 @@ It generates **architecture first** — grounded in a library of canonical patte
  • Local embeddings: compact multilingual sentence-transformer, on-box, 384-d
  • 21 node families: Table, DTO, Model, Service, Worker, Controller, Repository, Cache, Middleware, and more
  • 16 semantic edges: CALLS, QUERIES, WRITES, PUBLISHES, SUBSCRIBES, THROWS...
  • +
  • NestJS codegen: deterministic scaffold + optional Surgical AI fill
  • @@ -128,6 +135,7 @@ It generates **architecture first** — grounded in a library of canonical patte
  • Edge bundling & routing: obstacle-aware bezier and elbow paths
  • Type-safe API client: Zod → OpenAPI → openapi-fetch + openapi-typescript
  • Light Blueprint design: warm paper, semantic family colors, hairline grid, Satoshi + JetBrains Mono
  • +
  • Bring-your-own-key: OpenAI, Anthropic, Ollama, DeepSeek, … — your server, your quota
  • @@ -155,21 +163,61 @@ The output isn't *trustworthy* code. It's *provably correct* structure. --- -## ✦ Self-Hosting +## ✦ Try it + +Don't want to clone, configure, or run Docker? Use the hosted product: -The fastest way to use Solarch is the hosted app — **[app.solarch.dev](https://app.solarch.dev)** — no setup, always up to date. +| | Link | What you get | +|---|------|----------------| +| **App** | **[app.solarch.dev](https://app.solarch.dev)** | Full canvas — sign up and build in the browser. Always up to date. | +| **Website** | **[solarch.dev](https://solarch.dev)** | Product overview, demos, updates. | + +Zero local setup. If that fits you, stop reading here and open the app. + +--- + +## ✦ Self-Hosting -A **unified, one-command self-host** (the whole stack — backend, canvas, and a vector-native Neo4j — behind a single `docker compose up`) is **coming soon** to this repository. Watch / star to follow along. +Want the stack on **your machine** — your LLM key, your data, no vendor? This repository is the full OSS bundle: NestJS backend, canvas UI, vector-native Neo4j, local embeddings. ```bash -# Coming soon git clone https://github.com/solarch-dev/solarch.git cd solarch -docker compose up +./install.sh # Linux/macOS — Neo4j password + AI provider wizard +docker compose up --build # → http://localhost:3000 ``` -> Solarch's stack — NestJS + Zod + Neo4j backend, a custom Canvas 2D frontend, an agentic LLM layer, and local embeddings — will ship here as a single plug-and-play bundle. +No login screen. Bring your own LLM API key. By default Docker binds to **127.0.0.1** only. + +Windows: `./install.ps1`, then the same `docker compose` command. + +| Need | Details | +|------|---------| +| Docker + Compose v2 | Required | +| LLM provider | Tool-calling model — see [AI providers](docs/ai-providers.md) | +| LAN / VPS | Enable HTTP Basic Auth — [Self-hosting guide](docs/self-hosting.md) | + +Hosted alternative: **[app.solarch.dev](https://app.solarch.dev)** · **[solarch.dev](https://solarch.dev)** + +--- + +## ✦ Documentation + +Full guides live in [`docs/`](docs/README.md): + +| Guide | Topics | +|-------|--------| +| [Getting started](docs/getting-started.md) | First run, four surfaces, API keys | +| [Canvas & Rules Engine](docs/canvas-and-rules.md) | Nodes, edges, error codes | +| [AI Architect](docs/ai-architect.md) | Agent, Instruct, GraphRAG | +| [Codegen](docs/codegen.md) | NestJS scaffold, Surgical AI | +| [CLI & API keys](docs/cli-and-api-keys.md) | `solarch login`, MCP | +| [Self-hosting](docs/self-hosting.md) | Env, security, rate limits | +| [Development](docs/development.md) | pnpm dev, tests | +| [Deployment](docs/deployment.md) | Caddy, systemd, production | + +CLI / MCP / VS Code extension: [**solarch-tools**](https://github.com/solarch-dev/solarch-tools). --- diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8657c98 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Reporting a vulnerability + +Please **do not** open a public issue for security vulnerabilities. + +Report privately to **[info@solidea.tech](mailto:info@solidea.tech)** (or use GitHub's +[private vulnerability reporting](https://github.com/solarch-dev/solarch/security/advisories/new)). +Include a description, reproduction steps, and the impact you observed. We aim to acknowledge +within a few business days and will keep you updated on the fix. + +## Scope + +This repository is the Solarch application stack (`apps/web`, `apps/server`) and the +self-host bundle. The hosted service at [app.solarch.dev](https://app.solarch.dev) is also +in scope. Please report responsibly and give us reasonable time to remediate before any +public disclosure. + +## Handling secrets + +Never commit real credentials. All secrets are provided via environment variables — see +`.env.example`. The server actively redacts secret-shaped values, and the codegen pipeline +is tested to never emit secrets into generated output. diff --git a/apps/server/.env.example b/apps/server/.env.example new file mode 100644 index 0000000..c73f4ed --- /dev/null +++ b/apps/server/.env.example @@ -0,0 +1,25 @@ +NODE_ENV=development +PORT=4000 + +NEO4J_URI=bolt://localhost:7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=solarch_dev_password + +CORS_ORIGIN=http://localhost:3000 + +# Local owner identity when no API key is sent (OSS default). +LOCAL_USER_ID=local_owner + +# ── AI agent — optional (any OpenAI-compatible provider) ────────────── +# generation = architecture generation + tool calling, chat = general dialogue +LLM_GENERATION_PROVIDER=deepseek +LLM_CHAT_PROVIDER=deepseek +# Bedrock (OpenAI-compatible endpoint) + long-term bearer API key +# BEDROCK_API_KEY=ABSK-xxxxxxxx +# BEDROCK_BASE_URL=https://bedrock-mantle.us-east-1.api.aws/v1 +# AWS_REGION=us-east-1 +# BEDROCK_MODEL=moonshotai.kimi-k2.5 +# DeepSeek (proven tool calling) +DEEPSEEK_API_KEY=sk-xxxxxxxx +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +DEEPSEEK_MODEL=deepseek-v4-pro diff --git a/apps/server/.gitignore b/apps/server/.gitignore new file mode 100644 index 0000000..4bf2e76 --- /dev/null +++ b/apps/server/.gitignore @@ -0,0 +1,48 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build output +dist/ +build/ +*.tsbuildinfo + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Runtime +.cache/ +.tmp/ + +# Environment +.env +.env.local +.env.*.local + +# Editor / OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db +*.swp +*.swo + +# Test / coverage +coverage/ +.nyc_output/ + +# Neo4j local data +neo4j_data/ + +# DB backups (contains PII — do not go into repo) + backup settings file (RCLONE token etc.) +backups/ +scripts/neo4j-backup.env +scripts/.tr-en-cache.json + +# Claude Code +.claude/ diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile new file mode 100644 index 0000000..d8ffb41 --- /dev/null +++ b/apps/server/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 +# Solarch server (NestJS + Neo4j). Build context = repo root (pnpm workspace). + +FROM node:22-slim AS base +RUN corepack enable && corepack prepare pnpm@10.0.0 --activate +WORKDIR /app + +# ---- install + build ---- +FROM base AS build +# Workspace manifests first for a cached install layer. +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml turbo.json tsconfig.base.json ./ +COPY apps/web/package.json apps/web/package.json +COPY apps/server/package.json apps/server/package.json +RUN pnpm install --frozen-lockfile --filter @solarch/server... +COPY apps/server apps/server +RUN pnpm --filter @solarch/server build +# Pre-fetch the local embedding model so the first request is offline-ready (best-effort: +# a restricted build network falls back to downloading it on first use at runtime). +RUN cd apps/server && node -e "import('@xenova/transformers').then(m=>m.pipeline('feature-extraction','Xenova/paraphrase-multilingual-MiniLM-L12-v2')).then(()=>console.log('embed model cached')).catch(e=>console.error('embed prefetch skipped:',e.message))" || true + +# ---- runtime ---- +FROM base AS runtime +ENV NODE_ENV=production +WORKDIR /app +# tsx + src are kept so the entrypoint can run the (idempotent) DB bootstrap. +COPY --from=build /app/node_modules ./node_modules +# tsconfig.base.json is required at runtime: apps/server/tsconfig.json extends it, and the +# entrypoint runs the DB bootstrap via tsx (which reads the tsconfig for experimentalDecorators). +COPY --from=build /app/package.json /app/pnpm-workspace.yaml /app/turbo.json /app/tsconfig.base.json ./ +COPY --from=build /app/apps/server ./apps/server +COPY apps/server/docker-entrypoint.sh /usr/local/bin/solarch-entrypoint.sh +RUN chmod +x /usr/local/bin/solarch-entrypoint.sh +WORKDIR /app/apps/server +EXPOSE 4000 +ENTRYPOINT ["/usr/local/bin/solarch-entrypoint.sh"] diff --git a/apps/server/README.md b/apps/server/README.md new file mode 100644 index 0000000..ca1b608 --- /dev/null +++ b/apps/server/README.md @@ -0,0 +1,53 @@ +# Solarch Server (OSS) + +Architecture graph backend for the self-hosted Solarch monorepo. Node/edge CRUD, Rules Engine, GraphRAG, AI architect, and codegen. + +## Stack + +- **NestJS 11** — modular API, global Zod validation, typed error envelope +- **Neo4j 5** — graph storage + native vector index (GraphRAG) +- **Zod 4 + nestjs-zod** — node/edge schemas, OpenAPI in dev +- **LangChain** — multi-provider LLM (OpenAI, Anthropic, Ollama, DeepSeek, …) +- **@xenova/transformers** — local embeddings (default, offline-capable) +- **Vitest + Testcontainers** — unit and e2e tests + +## Auth (OSS) + +Every HTTP request is handled by **`LocalAuthGuard`**: + +- Browser / same-origin SPA → fixed local owner (`LOCAL_USER_ID`, default `local_owner`) +- CLI / MCP → `Authorization: Bearer slk_*` or `X-Solarch-Api-Key: slk_*` + +See [CLI & API keys](../../docs/cli-and-api-keys.md) and [Self-hosting](../../docs/self-hosting.md). + +## Dev commands + +```bash +pnpm install +pnpm neo4j:up +pnpm neo4j:migrate # from apps/server with .env +pnpm dev # http://localhost:4000/api/v1 +pnpm test # unit +pnpm test:e2e # e2e (Testcontainers, first run ~2 min) +``` + +## Environment + +Copy root [`.env.example`](../../.env.example) and see [`src/config/env.ts`](src/config/env.ts). Provider choice is required (`LLM_*_PROVIDER` + matching API key). + +## Documentation + +Full OSS documentation: **[`docs/README.md`](../../docs/README.md)** (index). + +| Guide | Link | +|-------|------| +| Getting started | [docs/getting-started.md](../../docs/getting-started.md) | +| Canvas & Rules | [docs/canvas-and-rules.md](../../docs/canvas-and-rules.md) | +| AI Architect | [docs/ai-architect.md](../../docs/ai-architect.md) | +| Codegen | [docs/codegen.md](../../docs/codegen.md) | +| Development | [docs/development.md](../../docs/development.md) | +| Deployment | [docs/deployment.md](../../docs/deployment.md) | + +## License + +[PolyForm Noncommercial](../../LICENSE) diff --git a/apps/server/deploy/Caddyfile b/apps/server/deploy/Caddyfile new file mode 100644 index 0000000..8c2bffb --- /dev/null +++ b/apps/server/deploy/Caddyfile @@ -0,0 +1,44 @@ +# Solarch — single-origin reverse proxy (Caddy). Copy to /etc/caddy/Caddyfile. +# Replace DOMAIN with your hostname. Caddy handles HTTPS (Let's Encrypt) automatically. +# +# Single origin: frontend and /api share one host so cookies and API keys stay on one origin. + +# www → apex redirect (avoid split-host issues). +www.DOMAIN { + redir https://DOMAIN{uri} permanent +} + +DOMAIN { + encode zstd gzip + + # /api/* → backend (listens on loopback only). Caddy adds X-Forwarded-*; + # backend trust proxy=1 for correct client IP (rate limits). + handle /api/* { + reverse_proxy 127.0.0.1:4000 + } + + # Remaining paths → Vite static build (SPA). try_files falls back to index.html. + handle { + root * /var/www/solarch/dist + try_files {path} /index.html + file_server + + # Hashed assets are immutable; index.html no-cache (new deploy must not serve stale HTML + # referencing old asset hashes). + @assets path /assets/* + header @assets Cache-Control "public, max-age=31536000, immutable" + @html path /index.html / + header @html Cache-Control "no-cache" + } + + header { + -Server + Referrer-Policy strict-origin-when-cross-origin + X-Content-Type-Options nosniff + X-Frame-Options DENY + } + + log { + output file /var/log/caddy/solarch.log + } +} diff --git a/apps/server/deploy/solarch-backend.service b/apps/server/deploy/solarch-backend.service new file mode 100644 index 0000000..ad12919 --- /dev/null +++ b/apps/server/deploy/solarch-backend.service @@ -0,0 +1,24 @@ +# Solarch backend (NestJS) — /etc/systemd/system/solarch-backend.service +# Adjust User/paths for your server. Enable: +# sudo systemctl daemon-reload && sudo systemctl enable --now solarch-backend + +[Unit] +Description=Solarch backend (NestJS) +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +User=solarch +WorkingDirectory=/opt/solarch/solarch-backend +# Backend .env (Neo4j/LLM keys) — mode 600, outside repo. +EnvironmentFile=/opt/solarch/solarch-backend/.env +ExecStart=/usr/bin/node dist/main.js +Restart=on-failure +RestartSec=3 +# enableShutdownHooks — allow graceful shutdown (Neo4j driver.close + in-flight). +TimeoutStopSec=20 +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/apps/server/deploy/solarch-neo4j-backup.service b/apps/server/deploy/solarch-neo4j-backup.service new file mode 100644 index 0000000..db89eb4 --- /dev/null +++ b/apps/server/deploy/solarch-neo4j-backup.service @@ -0,0 +1,9 @@ +# Neo4j daily backup — /etc/systemd/system/solarch-neo4j-backup.service +# Triggered by solarch-neo4j-backup.timer. Adjust path/user. +[Unit] +Description=Solarch Neo4j daily backup +After=docker.service + +[Service] +Type=oneshot +ExecStart=/opt/solarch/solarch-backend/scripts/neo4j-backup.sh diff --git a/apps/server/deploy/solarch-neo4j-backup.timer b/apps/server/deploy/solarch-neo4j-backup.timer new file mode 100644 index 0000000..bd6ea23 --- /dev/null +++ b/apps/server/deploy/solarch-neo4j-backup.timer @@ -0,0 +1,11 @@ +# Neo4j backup timer — /etc/systemd/system/solarch-neo4j-backup.timer +# Enable: sudo systemctl enable --now solarch-neo4j-backup.timer +[Unit] +Description=Solarch Neo4j backup scheduler (daily 04:00) + +[Timer] +OnCalendar=*-*-* 04:00:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/apps/server/docker-compose.yml b/apps/server/docker-compose.yml new file mode 100644 index 0000000..8772d9d --- /dev/null +++ b/apps/server/docker-compose.yml @@ -0,0 +1,30 @@ +services: + neo4j: + image: neo4j:5-community + container_name: solarch-neo4j +# Connect to loopback ONLY — Neo4j cannot be accessed from outside (backend is on the same host bolt://localhost). +# Prod security: 7474/7687 Should NEVER boot to 0.0.0.0. + ports: + - "127.0.0.1:7474:7474" + - "127.0.0.1:7687:7687" + environment: +# Password from .env (the .env in the backend directory is read by compose). giant default +# solarch_dev_password; Put strong NEO4J_PASSWORD in .env in PROD. NOTE: password only +# volume is written in the FIRST init — a reset is required to change it to the current volume. + NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-solarch_dev_password} + NEO4J_PLUGINS: '["apoc"]' +# Memory tuning (2vCPU/8GB single-box: shares Neo4j + backend + Caddy). Increase if necessary. + NEO4J_server_memory_heap_initial__size: ${NEO4J_HEAP:-512m} + NEO4J_server_memory_heap_max__size: ${NEO4J_HEAP:-512m} + NEO4J_server_memory_pagecache_size: ${NEO4J_PAGECACHE:-512m} + volumes: + - solarch_neo4j_data:/data + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:7474"] + interval: 5s + timeout: 3s + retries: 20 + restart: unless-stopped + +volumes: + solarch_neo4j_data: diff --git a/apps/server/docker-entrypoint.sh b/apps/server/docker-entrypoint.sh new file mode 100644 index 0000000..76a1443 --- /dev/null +++ b/apps/server/docker-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Solarch server entrypoint: idempotently bootstrap the graph DB, then start the API. +# Neo4j readiness is guaranteed by the compose healthcheck (depends_on: service_healthy); +# every step is idempotent (schema uses IF NOT EXISTS, the seed uses MERGE), so a restart +# is safe. Each step is best-effort — a failure is logged but never blocks the server. +set -e +cd /app/apps/server +TSX="node_modules/.bin/tsx" + +echo "[solarch] initializing graph database (idempotent) ..." +$TSX src/neo4j/migrations/run.ts || echo "[solarch] WARN: schema migration step failed" +$TSX src/neo4j/migrations/data/004-pattern-vector-index.ts || echo "[solarch] WARN: pattern vector index step failed" +$TSX src/patterns/seed/seed.ts || echo "[solarch] WARN: pattern seed step failed" + +echo "[solarch] starting API on ${HOST:-0.0.0.0}:${PORT:-4000} ..." +exec node dist/main.js diff --git a/apps/server/eslint.config.mjs b/apps/server/eslint.config.mjs new file mode 100644 index 0000000..68c6c11 --- /dev/null +++ b/apps/server/eslint.config.mjs @@ -0,0 +1,20 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import globals from "globals"; + +// Flat config (ESLint 9). Syntactic recommended + TS recommended; type-aware (parserOptions. +//project) OFF → fast + green gate without blowing up existing code. After hardening. +export default tseslint.config( + { ignores: ["dist", "node_modules", "coverage"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { globals: { ...globals.node } }, + rules: { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], +// `const self = this` is intentional in SSE/async-generator closures (cannot be an arrow generator). + "@typescript-eslint/no-this-alias": "warn", + }, + }, +); diff --git a/apps/server/nest-cli.json b/apps/server/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/server/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..334b30e --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,69 @@ +{ + "name": "@solarch/server", + "version": "0.1.0", + "description": "Solarch architecture graph backend — Node CRUD + Rules Engine", + "private": true, + "engines": { + "node": ">=20.19.0" + }, + "scripts": { + "build": "nest build", + "dev": "nest start --watch", + "start": "node dist/main.js", + "test": "vitest run", + "test:watch": "vitest", + "test:unit": "vitest run --exclude \"**/neo4j.service.spec.ts\" --exclude \"**/nodes.repository.spec.ts\"", + "test:docker": "vitest run src/neo4j/neo4j.service.spec.ts src/nodes/nodes.repository.spec.ts", + "test:e2e": "NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=placeholder LLM_GENERATION_PROVIDER=openai LLM_CHAT_PROVIDER=openai OPENAI_API_KEY=test PORT=4444 CORS_ORIGIN=http://localhost:3000 NODE_ENV=test vitest run --config vitest.e2e.config.ts", + "test:codegen-gate": "vitest run --config vitest.gate.config.ts", + "lint": "eslint \"src/**/*.ts\"", + "neo4j:up": "docker compose up -d", + "neo4j:down": "docker compose down", + "neo4j:migrate": "tsx --env-file=.env src/neo4j/migrations/run.ts" + }, + "dependencies": { + "@langchain/anthropic": "^1.5.1", + "@langchain/core": "^1.1.48", + "@langchain/deepseek": "^1.0.27", + "@langchain/google-genai": "^2.2.0", + "@langchain/groq": "^1.3.1", + "@langchain/mistralai": "^1.2.0", + "@langchain/ollama": "^1.3.0", + "@langchain/openai": "^1.4.7", + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/swagger": "^11.4.4", + "@nestjs/throttler": "^6.5.0", + "@scalar/nestjs-api-reference": "^1.1.17", + "@solarch/cli": "^0.8.0", + "@xenova/transformers": "^2.17.2", + "dotenv": "^17.4.2", + "helmet": "^8.2.0", + "neo4j-driver": "^5.27.0", + "nestjs-zod": "^5.4.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@eslint/js": "^9", + "@nestjs/cli": "^11.0.0", + "@nestjs/testing": "^11.0.0", + "@swc/core": "^1.15.33", + "@testcontainers/neo4j": "^12.0.0", + "@types/express": "^5.0.6", + "@types/node": "^22.10.0", + "@types/supertest": "^6.0.2", + "eslint": "^9", + "express": "^5.2.1", + "globals": "^17.6.0", + "supertest": "^7.0.0", + "testcontainers": "^10.16.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "typescript-eslint": "^8", + "unplugin-swc": "^1.5.9", + "vitest": "^2.1.8" + } +} diff --git a/apps/server/scripts/neo4j-backup.env.example b/apps/server/scripts/neo4j-backup.env.example new file mode 100644 index 0000000..39053d0 --- /dev/null +++ b/apps/server/scripts/neo4j-backup.env.example @@ -0,0 +1,12 @@ +# Solarch Neo4j backup settings. Copy → scripts/neo4j-backup.env (gitignored). +# All optional; script defaults apply when unset. + +# CONTAINER_NAME=solarch-neo4j +# IMAGE=neo4j:5-community +# BACKUP_DIR=/home/USER/solarch-backend/backups +# RETENTION_DAYS=7 +# HEALTH_TIMEOUT=60 + +# Offsite (optional) — rclone remote. Skipped when empty. +# Configure remote via rclone config (B2/GCS/S3), then: +# RCLONE_REMOTE=b2:solarch-backups diff --git a/apps/server/scripts/neo4j-backup.sh b/apps/server/scripts/neo4j-backup.sh new file mode 100755 index 0000000..b475442 --- /dev/null +++ b/apps/server/scripts/neo4j-backup.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# Solarch — Neo4j otomatik yedek (Neo4j 5 Community, Docker). +# +# ONLINE dump/backup NONETUR ("database is in use" error) in Neo4j Community +# one way: stop container → offline dump with ephemeral container → start. +# Interrupt window is small, a few seconds on DB (4am cron recommended). +# +# SECURITY RULE: With trap, the container is restarted on EVERY exit (including error) +# → even failed backup won't leave the DB down. +# +# Usage: scripts/neo4j-backup.sh (from root cron; docker access required) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# ── Ayarlar (scripts/neo4j-backup.env varsa override eder) ──────────────────── +CONTAINER_NAME="solarch-neo4j" +IMAGE="neo4j:5-community" +BACKUP_DIR="${BACKEND_DIR}/backups" +RETENTION_DAYS=7 +RCLONE_REMOTE="" # e.g. "b2:solarch-backups" — if empty, offsite is skipped +HEALTH_TIMEOUT=60 # 'healthy' wait after restart (sec) +# shellcheck disable=SC1090 +[ -f "${SCRIPT_DIR}/neo4j-backup.env" ] && . "${SCRIPT_DIR}/neo4j-backup.env" + +# ── NEO4J identity (from backend .env; does not require dump auth, for smoke testing) ─── +NEO4J_USER="neo4j" +NEO4J_PASSWORD="" +if [ -f "${BACKEND_DIR}/.env" ]; then + set -a; # shellcheck disable=SC1091 + . "${BACKEND_DIR}/.env"; set +a +fi + +# ── Docker access (sudo if not root) ──────────────────── ──────────────────── +if docker ps >/dev/null 2>&1; then DOCKER="docker"; else DOCKER="sudo docker"; fi + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } + +# SECURITY: restart container no matter what. +restart_container() { ${DOCKER} start "${CONTAINER_NAME}" >/dev/null 2>&1 || true; } +trap restart_container EXIT + +TS="$(date +%Y%m%d-%H%M%S)" +DEST="${BACKUP_DIR}/${TS}" +mkdir -p "${DEST}" +START_EPOCH=$(date +%s) + +log "Backup starts → ${DEST}" +log "Container durduruluyor (${CONTAINER_NAME})…" +${DOCKER} stop "${CONTAINER_NAME}" >/dev/null + +# system + neo4j: get both (system → auth/role data; required on restore). +for DB in system neo4j; do + log "dump ${DB}…" +# --to-stdout → host gzip file (bypasses bind-mount permission issue) + ${DOCKER} run --rm --volumes-from "${CONTAINER_NAME}" "${IMAGE}" \ + neo4j-admin database dump "${DB}" --to-stdout 2>>"${DEST}/dump.log" \ + | gzip > "${DEST}/${DB}.dump.gz" + if [ "${PIPESTATUS[0]}" -ne 0 ] || [ ! -s "${DEST}/${DB}.dump.gz" ]; then +log "ERROR: ${DB} dump failed (see ${DEST}/dump.log)"; exit 1 + fi +done + +log "Initializing container…" +${DOCKER} start "${CONTAINER_NAME}" >/dev/null + +# Health bekle +WAITED=0 +until [ "$(${DOCKER} inspect -f '{{.State.Health.Status}}' "${CONTAINER_NAME}" 2>/dev/null)" = "healthy" ]; do + sleep 2; WAITED=$((WAITED+2)) +if [ "${WAITED}" -ge "${HEALTH_TIMEOUT}" ]; then log "WARNING: Health was not achieved in ${HEALTH_TIMEOUT}sec"; break; fi +done + +# Smoke test (parola varsa) +if [ -n "${NEO4J_PASSWORD}" ]; then + CNT=$(${DOCKER} exec "${CONTAINER_NAME}" cypher-shell -u "${NEO4J_USER}" -p "${NEO4J_PASSWORD}" \ + --format plain "MATCH (n) RETURN count(n)" 2>/dev/null | tail -1 || echo "?") +log "Smoke: number of nodes = ${CNT}" +fi + +# latest symlink + retention +ln -sfn "${DEST}" "${BACKUP_DIR}/latest" +find "${BACKUP_DIR}" -maxdepth 1 -type d -name '20*' -mtime "+${RETENTION_DAYS}" -exec rm -rf {} + 2>/dev/null || true + +# Offsite (opsiyonel) +if [ -n "${RCLONE_REMOTE}" ] && command -v rclone >/dev/null 2>&1; then + log "Offsite → ${RCLONE_REMOTE}/neo4j/${TS}" +rclone copy "${DEST}" "${RCLONE_REMOTE}/neo4j/${TS}" --transfers=4 || log "WARNING: offsite copy failed" +elif [ -n "${RCLONE_REMOTE}" ]; then +log "WARNING: RCLONE_REMOTE set but rclone not installed — offsite skipped" +fi + +SIZE=$(du -sh "${DEST}" | cut -f1) +log "Yedek tamam (${SIZE}, $(( $(date +%s) - START_EPOCH ))sn). Retention=${RETENTION_DAYS}g." diff --git a/apps/server/scripts/neo4j-restore.sh b/apps/server/scripts/neo4j-restore.sh new file mode 100755 index 0000000..14358aa --- /dev/null +++ b/apps/server/scripts/neo4j-restore.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# Solarch — Neo4j restore (Neo4j 5 Community, Docker). +# Restores system + neo4j dumps from a backup directory. +# Restore also requires container stop (load cannot change the running DB). +# +# Usage: scripts/neo4j-restore.sh [BACKUP_DIR] (or backups/latest) +# +# CAUTION: destination DB is OVERWRITTEN (--overwrite-destination). Confirmation is requested. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +CONTAINER_NAME="solarch-neo4j" +IMAGE="neo4j:5-community" +BACKUP_DIR="${BACKEND_DIR}/backups" +# shellcheck disable=SC1090 +[ -f "${SCRIPT_DIR}/neo4j-backup.env" ] && . "${SCRIPT_DIR}/neo4j-backup.env" + +SRC="${1:-${BACKUP_DIR}/latest}" +[ -d "${SRC}" ] || { echo "HATA: yedek dizini yok: ${SRC}"; exit 1; } +[ -s "${SRC}/neo4j.dump.gz" ] || { echo "ERROR: ${SRC}/neo4j.dump.gz does not exist/empty"; exit 1; } + +if docker ps >/dev/null 2>&1; then DOCKER="docker"; else DOCKER="sudo docker"; fi +log() { echo "[$(date '+%H:%M:%S')] $*"; } + +echo "CAUTION: Backup '${SRC}' will OVERwrite the CURRENT database (irreversible)." +read -r -p "Type 'yes' to continue: " ans +[ "${ans}" = "yes" ] || { echo "Cancel."; exit 1; } + +restart_container() { ${DOCKER} start "${CONTAINER_NAME}" >/dev/null 2>&1 || true; } +trap restart_container EXIT + +log "Container durduruluyor…" +${DOCKER} stop "${CONTAINER_NAME}" >/dev/null + +#load system FIRST from neo4j (auth/role data). +for DB in system neo4j; do + log "load ${DB}…" + gunzip -c "${SRC}/${DB}.dump.gz" \ + | ${DOCKER} run --rm -i --volumes-from "${CONTAINER_NAME}" "${IMAGE}" \ + neo4j-admin database load "${DB}" --from-stdin --overwrite-destination +done + +log "Initializing container…" +${DOCKER} start "${CONTAINER_NAME}" >/dev/null +log "Restore ok. NOTE: don't forget to run 'pnpm neo4j:migrate' for new schema changes." diff --git a/apps/server/src/ai/ai-idempotency.store.spec.ts b/apps/server/src/ai/ai-idempotency.store.spec.ts new file mode 100644 index 0000000..538eb74 --- /dev/null +++ b/apps/server/src/ai/ai-idempotency.store.spec.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { AiIdempotencyStore } from "./ai-idempotency.store"; + +describe("AiIdempotencyStore", () => { + it("claims first-seen requestId (true), rejects duplicate (false)", () => { + const store = new AiIdempotencyStore(); + expect(store.tryAcquire("req-1")).toBe(true); + expect(store.tryAcquire("req-1")).toBe(false); + expect(store.tryAcquire("req-1")).toBe(false); + }); + + it("different requestIds are independent", () => { + const store = new AiIdempotencyStore(); + expect(store.tryAcquire("a")).toBe(true); + expect(store.tryAcquire("b")).toBe(true); + expect(store.tryAcquire("a")).toBe(false); + expect(store.tryAcquire("b")).toBe(false); + }); +}); diff --git a/apps/server/src/ai/ai-idempotency.store.ts b/apps/server/src/ai/ai-idempotency.store.ts new file mode 100644 index 0000000..a8f806c --- /dev/null +++ b/apps/server/src/ai/ai-idempotency.store.ts @@ -0,0 +1,42 @@ +import { Injectable } from "@nestjs/common"; + +/** SSE `chat/stream` duplicate-trigger protection. + * + * When an EventSource connection drops, the browser may reopen the same URL + * (auto-reconnect) or the user may double-submit quickly. If the same + * `requestId` arrives again, generation reruns and creates duplicate nodes. This store "claims" the first seen requestId for the TTL + * and rejects repeats. + * + * In-memory, single-instance only. Move to Redis when deploying multi-instance + * (single-box launch is sufficient for now). */ +@Injectable() +export class AiIdempotencyStore { + private readonly seen = new Map(); // requestId → expiry (epoch ms) + private readonly ttlMs = 5 * 60_000; + private readonly maxKeys = 10_000; // cap against pathological growth + + /** Returns `true` on first sight (claimed). Returns `false` if seen again within TTL. */ + tryAcquire(key: string): boolean { + const now = Date.now(); + this.sweep(now); + const exp = this.seen.get(key); + if (exp !== undefined && exp > now) return false; + this.seen.set(key, now + this.ttlMs); + return true; + } + + private sweep(now: number): void { + if (this.seen.size === 0) return; + for (const [k, exp] of this.seen) { + if (exp <= now) this.seen.delete(k); + } + if (this.seen.size > this.maxKeys) { + const excess = this.seen.size - this.maxKeys; + let i = 0; + for (const k of this.seen.keys()) { + if (i++ >= excess) break; + this.seen.delete(k); + } + } + } +} diff --git a/apps/server/src/ai/ai.controller.ts b/apps/server/src/ai/ai.controller.ts new file mode 100644 index 0000000..5b11d94 --- /dev/null +++ b/apps/server/src/ai/ai.controller.ts @@ -0,0 +1,147 @@ +import { Body, Controller, Param, Post, HttpCode, Sse, Query, Req, UseGuards, type MessageEvent } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiParam, ApiResponse } from "@nestjs/swagger"; +import { Throttle } from "@nestjs/throttler"; +import { ProjectAccessGuard } from "../auth/project-access.guard"; +import { from, type Observable } from "rxjs"; +import { map } from "rxjs/operators"; +import { AiService } from "./ai.service"; +import { AiIdempotencyStore } from "./ai-idempotency.store"; +import { CurrentAuth } from "../auth/current-auth.decorator"; +import type { AuthContext } from "../auth/auth.types"; +import { ChatDto, MAX_MESSAGE_CHARS, MAX_HISTORY_ITEMS } from "./dto/chat.dto"; +import { ok } from "../common/envelope"; +import type { ChatResponse, StreamEvent } from "./dto/chat-response.dto"; +import type { ChatInput } from "./dto/chat.dto"; + +@ApiTags("AI Agent") +@UseGuards(ProjectAccessGuard) +// AI endpoints are expensive: 20 req/min per user (stricter than global 60). +@Throttle({ default: { ttl: 60_000, limit: 20 } }) +@Controller("projects/:projectId/ai") +export class AiController { + constructor( + private readonly service: AiService, + private readonly idem: AiIdempotencyStore, + ) {} + + @Post("chat") + @HttpCode(200) + @ApiOperation({ + summary: "Chat with the AI architect (generate architecture)", + description: + "Passes the natural-language request to the 'Chief Software Architect' AI. The AI sees the current graph (current_graph), " + + "generates architecture via atomic `create_node` / `create_edge` tools, and **self-corrects** on Rules Engine violations " + + "(ReAct self-correction, max 3 attempts). Generation: Bedrock/Claude, tool calling.\n\n" + + "Response: `{ reply, applied?: {idMap, nodeCount, edgeCount}, attempts }`. " + + "If `applied` is populated, the architecture has been added to the canvas.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "AI response + (if any) the applied architecture." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + @ApiResponse({ status: 503, description: "`ERR_AI_NOT_CONFIGURED` — LLM API key missing." }) + async chat( + @Param("projectId") projectId: string, + @Body() body: ChatDto, + @CurrentAuth() _auth: AuthContext, + ): Promise { + const result = await this.service.chat(projectId, body as any); + return ok(result); + } + + @Sse("chat/stream") + @ApiOperation({ + summary: "AI architect — streaming (SSE, atomic create_node/create_edge tool agent loop)", + description: + "Opens an EventSource connection. The AI creates each node/edge one by one with the `create_node`/`create_edge` tools; " + + "the backend pushes an SSE event after each tool execution. Event types:\n" + + "- `event: node` — created node ({id, type, properties, ...})\n" + + "- `event: edge` — created edge\n" + + "- `event: done` — completed ({message, counts, attempts})\n" + + "- `event: error` — error ({code, message})\n\n" + + "GET is used (EventSource native limitation). history is a JSON-encoded query param.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + chatStream( + @Param("projectId") projectId: string, + @Query("message") message: string, + @CurrentAuth() _auth: AuthContext, + @Req() req: { on(event: "close", cb: () => void): void }, + @Query("tabId") tabId?: string, + @Query("history") historyJson?: string, + @Query("mode") mode?: string, + @Query("requestId") requestId?: string, + @Query("continue") cont?: string, + ): Observable { + const history = historyJson ? safeParseHistory(historyJson) : []; + const safeMode: "agent" | "instruct" = mode === "instruct" ? "instruct" : "agent"; + const input: ChatInput = { message, tabId, history, mode: safeMode, continueRun: cont === "true" }; + const self = this; + + // On client disconnect (tab closed / abort / network drop) stop in-flight LLM + // calls and DB writes → avoid wasted cost + stray data. + const ac = new AbortController(); + req.on("close", () => ac.abort()); + + async function* guarded(): AsyncGenerator { + // Raw SSE query bypasses DTO validation — bound input here. + if (!message || message.length > MAX_MESSAGE_CHARS || history.length > MAX_HISTORY_ITEMS) { + yield { type: "error", code: "ERR_SCHEMA_INVALID", message: "Invalid or too long input." } as StreamEvent; + return; + } + // Idempotency: same requestId (reconnect / double submit) must not re-run + // generation → duplicate nodes. + if (requestId && !self.idem.tryAcquire(requestId)) { + yield { + type: "error", + code: "ERR_DUPLICATE_REQUEST", + message: "This request is already being processed (duplicate connection ignored).", + } as StreamEvent; + return; + } + try { + for await (const event of self.service.chatStream(projectId, input, ac.signal)) { + yield event; + } + } catch (e) { + const r = (e as { getResponse?: () => { code?: string; message?: string } }).getResponse?.() ?? {}; + yield { type: "error", code: r.code ?? "ERR_AI_FAILED", message: r.message ?? "AI request failed." } as StreamEvent; + } + } + return from(guarded()).pipe( + map((event: StreamEvent) => { + // Flatten payload: type in SSE header; data is bare payload. + // Frontend JSON.parse(e.data) gets node/edge/payload directly. + switch (event.type) { + case "node": + case "edge": + case "removed": + return { data: event.data, type: event.type }; + case "text-delta": + return { data: { delta: event.delta }, type: "text-delta" }; + case "done": + return { + data: { message: event.message, counts: event.counts, attempts: event.attempts }, + type: "done", + }; + case "paused": + return { + data: { code: event.code, message: event.message, counts: event.counts, attempts: event.attempts }, + type: "paused", + }; + case "error": + return { data: { code: event.code, message: event.message }, type: "error" }; + } + }), + ); + } +} + +function safeParseHistory(json: string): Array<{ role: "user" | "assistant"; content: string }> { + try { + const parsed = JSON.parse(json); + if (Array.isArray(parsed)) return parsed; + return []; + } catch { + return []; + } +} diff --git a/apps/server/src/ai/ai.module.ts b/apps/server/src/ai/ai.module.ts new file mode 100644 index 0000000..1fd1678 --- /dev/null +++ b/apps/server/src/ai/ai.module.ts @@ -0,0 +1,17 @@ +import { Module } from "@nestjs/common"; +import { AiController } from "./ai.controller"; +import { AiService } from "./ai.service"; +import { AiIdempotencyStore } from "./ai-idempotency.store"; +import { ProjectsModule } from "../projects/projects.module"; +import { GraphModule } from "../graph/graph.module"; +import { PatternsModule } from "../patterns/patterns.module"; +import { NodesModule } from "../nodes/nodes.module"; +import { EdgesModule } from "../edges/edges.module"; +import { TabsModule } from "../tabs/tabs.module"; + +@Module({ + imports: [ProjectsModule, GraphModule, PatternsModule, NodesModule, EdgesModule, TabsModule], + controllers: [AiController], + providers: [AiService, AiIdempotencyStore], +}) +export class AiModule {} diff --git a/apps/server/src/ai/ai.service.spec.ts b/apps/server/src/ai/ai.service.spec.ts new file mode 100644 index 0000000..d339d5a --- /dev/null +++ b/apps/server/src/ai/ai.service.spec.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ConflictException } from "@nestjs/common"; + +// LLM factory mock — scripted AIMessage queue (each invoke returns the next item). +// If an Error instance is returned, invoke throws (exception scenario). +const h = vi.hoisted(() => ({ responses: [] as any[], idx: 0, onInvoke: null as null | (() => void), sawContractCheck: false })); + +vi.mock("./providers/llm.factory", () => ({ + isGenerationConfigured: () => true, + getGenerationChat: () => ({ + bindTools: () => ({ + invoke: async (messages: any) => { + h.onInvoke?.(); + for (const m of messages ?? []) { + if (typeof m?.content === "string" && m.content.includes("CONTRACT_CHECK")) h.sawContractCheck = true; + } + const r = h.responses[h.idx] ?? { content: "", tool_calls: [] }; + h.idx++; + if (r instanceof Error) throw r; + return r; + }, + }), + }), +})); + +import { AiService } from "./ai.service"; + +const projectId = "550e8400-e29b-41d4-a716-446655440001"; +const A = "11111111-1111-1111-1111-111111111111"; +const B = "22222222-2222-2222-2222-222222222222"; +const C = "33333333-3333-3333-3333-333333333333"; + +const aiMsg = (toolCalls: any[] = [], content = "") => ({ content, tool_calls: toolCalls }); +const nodeCall = (type: string, props: Record) => ({ id: "tc", name: "create_node", args: { type, properties: props } }); +const edgeCall = (s: string, t: string) => ({ id: "te", name: "create_edge", args: { sourceNodeId: s, targetNodeId: t, kind: "USES" } }); + +function makeService() { + const ids = [A, B, C]; + let createIdx = 0; + const nodes = { + create: vi.fn(async (_p: string, input: any) => ({ id: ids[createIdx++], type: input.type, properties: input.properties })), + delete: vi.fn(async () => true), + list: vi.fn(async () => [] as any[]), // contract-check (default: no gaps) + }; + const edges = { + create: vi.fn(async (_p: string, input: any) => ({ id: "edge-1", sourceNodeId: input.sourceNodeId, targetNodeId: input.targetNodeId, kind: input.kind, properties: input.properties })), + list: vi.fn(async () => [] as any[]), + }; + const projectsRepo = { exists: vi.fn(async () => true), getGraph: vi.fn(async () => ({ nodes: [], edges: [] })) }; + const patterns = { search: vi.fn(async () => []) }; + const tabs = { ensureDefault: vi.fn(async () => ({ id: "tab-1" })) }; + const service = new AiService(projectsRepo as any, {} as any, patterns as any, nodes as any, edges as any, tabs as any); + return { service, nodes, edges }; +} + +async function collect(gen: AsyncGenerator) { + const out: any[] = []; + for await (const ev of gen) out.push(ev); + return out; +} + +const input = { message: "build", history: [], mode: "agent" as const, continueRun: false }; + +describe("AiService.chatStreamAgent — orphan rollback", () => { + beforeEach(() => { h.responses = []; h.idx = 0; h.onInvoke = null; h.sawContractCheck = false; }); + + it("after correction limit orphan node is removed, connected ones preserved", async () => { + // turn1: create A,B · turn2: A→B edge + C(orphan) · turn3-5: no tools (done + 2 corrections) + h.responses = [ + aiMsg([nodeCall("Service", { ServiceName: "A" }), nodeCall("Service", { ServiceName: "B" })]), + aiMsg([edgeCall(A, B), nodeCall("Service", { ServiceName: "C" })]), + aiMsg([]), aiMsg([]), aiMsg([], "done"), + ]; + const { service, nodes } = makeService(); + const ev = await collect(service.chatStream(projectId, input)); + + // C deleted (orphan), A/B kept + expect(nodes.delete).toHaveBeenCalledTimes(1); + expect(nodes.delete).toHaveBeenCalledWith(projectId, C); + const removed = ev.filter((e) => e.type === "removed"); + expect(removed).toHaveLength(1); + expect(removed[0].data.id).toBe(C); + const done = ev.find((e) => e.type === "done"); + expect(done.counts.nodes).toBe(2); // A,B remain + }); + + it("on exception, orphan created so far is cleaned up + error event flows", async () => { + h.responses = [aiMsg([nodeCall("Service", { ServiceName: "A" })]), new Error("llm crashed")]; + const { service, nodes } = makeService(); + const ev = await collect(service.chatStream(projectId, input)); + + expect(nodes.delete).toHaveBeenCalledWith(projectId, A); + expect(ev.some((e) => e.type === "removed" && e.data.id === A)).toBe(true); + expect(ev.some((e) => e.type === "error" && e.code === "ERR_AI_GENERATION_FAILED")).toBe(true); + }); + + it("on abort no rollback (partial graph stays saved — current contract)", async () => { + const ac = new AbortController(); + ac.abort(); // cancel from the start + h.responses = [aiMsg([nodeCall("Service", { ServiceName: "A" })])]; + const { service, nodes } = makeService(); + const ev = await collect(service.chatStream(projectId, input, ac.signal)); + + expect(nodes.delete).not.toHaveBeenCalled(); + expect(ev.some((e) => e.type === "removed")).toBe(false); + expect(ev.some((e) => e.type === "error")).toBe(false); // silent return + }); + + it("step limit (MAX_TURNS) → paused event; orphan NOT cleaned (preserved for Continue)", async () => { + // Each turn creates a valid node (success → breaker not triggered) + never stops → + // runs until MAX_TURNS (env default 120) → paused (not error/done). + let n = 0; + h.responses = Array.from({ length: 130 }, () => aiMsg([nodeCall("Service", { ServiceName: "S" })])); + const { service, nodes } = makeService(); + nodes.create.mockImplementation(async (_p: string, inp: any) => ({ id: "n" + n++, type: inp.type, properties: inp.properties })); + const ev = await collect(service.chatStream(projectId, input)); + + const paused = ev.find((e) => e.type === "paused"); + expect(paused).toBeTruthy(); + expect(paused.code).toBe("MAX_TURNS_REACHED"); + // Orphan NOT cleaned — partial architecture preserved for Continue. + expect(nodes.delete).not.toHaveBeenCalled(); + expect(ev.some((e) => e.type === "removed")).toBe(false); + expect(ev.some((e) => e.type === "error")).toBe(false); + expect(ev.some((e) => e.type === "done")).toBe(false); + }, 20_000); + + it("circuit breaker: repeated illegal edge does not thrash until MAX_TURNS", async () => { + // turn1: create A,B · turn2+: same A→B edge (illegal) 30 times. Without breaker + // would thrash 30+ turns; breaker should stop after ~8 consecutive failures. + h.responses = [ + aiMsg([nodeCall("Service", { ServiceName: "A" }), nodeCall("Service", { ServiceName: "B" })]), + ...Array.from({ length: 30 }, () => aiMsg([edgeCall(A, B)])), + ]; + const { service, nodes, edges } = makeService(); + edges.create.mockRejectedValue(new ConflictException({ code: "ERR_NOT_WHITELISTED", message: "not allowed" })); + const ev = await collect(service.chatStream(projectId, input)); + + // Stopped early: well before 30 illegal turns (A,B turn + ~8 consecutive failures). + expect(h.idx).toBeLessThanOrEqual(10); + // Same edge hit DB only ONCE; rest short-circuited (token savings). + expect(edges.create).toHaveBeenCalledTimes(1); + // Graceful done (not ERR_MAX_TURNS error). + const done = ev.find((e) => e.type === "done"); + expect(done).toBeTruthy(); + expect(done.message).toContain("violate the architecture rules"); + expect(ev.some((e) => e.type === "error" && e.code === "ERR_MAX_TURNS")).toBe(false); + // A,B could not connect → orphans cleaned up. + expect(ev.filter((e) => e.type === "removed")).toHaveLength(2); + expect(nodes.delete).toHaveBeenCalledTimes(2); + }); + + it("contract-check: body-field endpoint without input DTO → CONTRACT_CHECK fed back to LLM", async () => { + // Graph produced by diagram-AI: Controller with POST endpoint without RequestDTORef + // (lintContracts Rule 1 gap). When LLM says "done", contract-check triggers feedback. + const ctrlNode = { + id: "c1", type: "Controller", + properties: { + ControllerName: "OrderController", Description: "order", BaseRoute: "orders", + Endpoints: [{ HttpMethod: "POST", Route: "/", RequiresAuth: false, RequiredRoles: [], PathParams: [], QueryParams: [], StatusCodes: [], MiddlewareRefs: [] }], + }, + }; + // LLM calls no tools (immediate done) → no orphan → contract-check; gap always present (LLM does not fix in sim). + h.responses = [aiMsg([], "done"), aiMsg([], "retry"), aiMsg([], "done")]; + const { service, nodes } = makeService(); + nodes.list.mockResolvedValue([ctrlNode] as any); + await collect(service.chatStream(projectId, input)); + expect(h.sawContractCheck).toBe(true); // contract gap reported to LLM at generation time + }); + + it("contract-check: graph complete (no gaps) → no CONTRACT_CHECK feedback", async () => { + h.responses = [aiMsg([], "done")]; + const { service } = makeService(); // nodes.list default [] → no gaps + await collect(service.chatStream(projectId, input)); + expect(h.sawContractCheck).toBe(false); + }); + + it("if backend closes (Pool is closed) → stop immediately, no LLM correction turn, no cleanup", async () => { + // turn1: create A (success) · turn2: create B → DB pool closed (hot-reload/SIGTERM). + // Old error classification would treat as ERR_INTERNAL and thrash 8+ "fix" turns. + h.responses = [ + aiMsg([nodeCall("Service", { ServiceName: "A" })]), + ...Array.from({ length: 20 }, () => aiMsg([nodeCall("Service", { ServiceName: "B" })])), + ]; + const { service, nodes } = makeService(); + let call = 0; + nodes.create.mockImplementation(async (_p: string, inp: any) => { + call++; + if (call >= 2) throw new Error("Pool is closed, it is no more able to serve requests."); + return { id: A, type: inp.type, properties: inp.properties }; + }); + const ev = await collect(service.chatStream(projectId, input)); + + // Stopped immediately: no thrash (only 2 LLM turns — A success + B closed pool). + expect(h.idx).toBeLessThanOrEqual(2); + // Cleanup futile (pool closed) → NOT attempted. + expect(nodes.delete).not.toHaveBeenCalled(); + expect(ev.some((e) => e.type === "removed")).toBe(false); + // Clear terminal signal — not ERR_INTERNAL/ERR_AI_GENERATION_FAILED. + expect(ev.some((e) => e.type === "error" && e.code === "ERR_BACKEND_UNAVAILABLE")).toBe(true); + }); + + it("if delete throws during cleanup stream still emits error event without crashing", async () => { + h.responses = [aiMsg([nodeCall("Service", { ServiceName: "A" })]), new Error("boom")]; + const { service, nodes } = makeService(); + nodes.delete.mockRejectedValueOnce(new Error("delete db error")); + const ev = await collect(service.chatStream(projectId, input)); + // delete failed but error event still arrived (each delete in its own try/catch) + expect(ev.some((e) => e.type === "error" && e.code === "ERR_AI_GENERATION_FAILED")).toBe(true); + }); +}); diff --git a/apps/server/src/ai/ai.service.ts b/apps/server/src/ai/ai.service.ts new file mode 100644 index 0000000..750722b --- /dev/null +++ b/apps/server/src/ai/ai.service.ts @@ -0,0 +1,873 @@ +import { HttpException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from "@nestjs/common"; +import { SystemMessage, HumanMessage, AIMessage, ToolMessage, type BaseMessage } from "@langchain/core/messages"; +import { z } from "zod"; +import { ProjectsRepository } from "../projects/projects.repository"; +import { GraphService } from "../graph/graph.service"; +import { PatternsService } from "../patterns/patterns.service"; +import { NodesService } from "../nodes/nodes.service"; +import { EdgesService } from "../edges/edges.service"; +import { TabsService } from "../tabs/tabs.service"; +import type { PatternSearchHit } from "../patterns/patterns.repository"; +import type { NodeKind } from "../nodes/schemas"; +import type { EdgeKind } from "../edges/schemas/edge.schema"; +import { buildCodeGraph } from "../codegen/ir"; +import { lintContracts } from "../codegen/contract-lint"; +import type { StoredNode } from "../nodes/nodes.repository"; +import type { StoredEdge } from "../edges/edges.repository"; +import { env } from "../config/env"; +import { getGenerationChat, isGenerationConfigured } from "./providers/llm.factory"; +import { buildSystemPrompt } from "./prompts/system-prompt"; +import { ApplyArchitectureArgsSchema } from "./tools/apply-architecture-graph.tool"; +import { + CREATE_NODE_TOOL_NAME, + CREATE_NODE_DESCRIPTION, + CreateNodeArgsSchema, +} from "./tools/create-node.tool"; +import { + CREATE_EDGE_TOOL_NAME, + CREATE_EDGE_DESCRIPTION, + CreateEdgeArgsSchema, +} from "./tools/create-edge.tool"; +import { GET_NODE_TOOL_NAME, GET_NODE_DESCRIPTION, GetNodeArgsSchema } from "./tools/get-node.tool"; +import { UPDATE_NODE_TOOL_NAME, UPDATE_NODE_DESCRIPTION, UpdateNodeArgsSchema } from "./tools/update-node.tool"; +import { DELETE_NODE_TOOL_NAME, DELETE_NODE_DESCRIPTION, DeleteNodeArgsSchema } from "./tools/delete-node.tool"; +import { DELETE_EDGE_TOOL_NAME, DELETE_EDGE_DESCRIPTION, DeleteEdgeArgsSchema } from "./tools/delete-edge.tool"; +import type { ChatInput } from "./dto/chat.dto"; +import type { ChatResult, StreamEvent } from "./dto/chat-response.dto"; + +const MAX_ATTEMPTS = 5; + +/** Structured output schema — apply input ({nodes, edges}) + summary. */ +const GenerationSchema = z.object({ + summary: z.string().optional(), + nodes: ApplyArchitectureArgsSchema.shape.nodes, + edges: ApplyArchitectureArgsSchema.shape.edges.default([]), +}); +type Generation = z.infer; + +function textOf(msg: AIMessage): string { + if (typeof msg.content === "string") return msg.content; + if (Array.isArray(msg.content)) { + return msg.content.map((c) => (typeof c === "string" ? c : "text" in c ? (c as any).text : "")).join(""); + } + return ""; +} + +function safeJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** Extract first valid JSON object from json_object output (tolerates markdown fences etc.). */ +function extractJson(text: string): string { + let t = text.trim(); + const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fence) t = fence[1].trim(); + const start = t.indexOf("{"); + const end = t.lastIndexOf("}"); + return start >= 0 && end > start ? t.slice(start, end + 1) : t; +} + +/** json mode: no tools → embed schema in prompt + 'json' keyword (DeepSeek requirement). */ +const JSON_DIRECTIVE = ` + +## OUTPUT FORMAT (REQUIRED) +In this mode there are NO functions/tools. Reply with ONLY valid JSON (no markdown, backticks, or extra text). JSON schema: +{ + "summary": "short summary (respond in English — the product UI is English)", + "nodes": [{ "tempId": "temp_x", "type": "Controller", "properties": { ... } }], + "edges": [{ "sourceTempId": "temp_x", "targetTempId": "temp_y", "edgeType": "CALLS", "label": "optional" }] +} +Every node must carry tempId; edges reference those tempIds.`; + +@Injectable() +export class AiService { + private readonly logger = new Logger(AiService.name); + + constructor( + private readonly projectsRepo: ProjectsRepository, + private readonly graphService: GraphService, + private readonly patterns: PatternsService, + private readonly nodes: NodesService, + private readonly edges: EdgesService, + private readonly tabs: TabsService, + ) {} + + async chat(projectId: string, input: ChatInput): Promise { + if (!isGenerationConfigured()) { + throw new ServiceUnavailableException({ + code: "ERR_AI_NOT_CONFIGURED", + message: "AI agent is not configured (BEDROCK_API_KEY / DEEPSEEK_API_KEY missing).", + }); + } + if (!(await this.projectsRepo.exists(projectId))) { + throw new NotFoundException({ code: "ERR_PROJECT_NOT_FOUND", message: `Project '${projectId}' not found.` }); + } + + const { nodes, edges } = await this.projectsRepo.getGraph(projectId); + + // GraphRAG: fetch nearest canonical patterns (degrades to empty when no embedding). + let patternHits: PatternSearchHit[] = []; + try { + patternHits = await this.patterns.search(input.message, env.EMBED_TOP_K, env.EMBED_MIN_SCORE); + } catch (e) { + this.logger.warn(`Pattern retrieval skipped: ${(e as Error).message}`); + } + + const systemPrompt = + buildSystemPrompt( + // graphRevision unused in prompt context — placeholder 0. + { project: { id: projectId } as any, nodes, edges, counts: { nodes: nodes.length, edges: edges.length }, graphRevision: 0 }, + patternHits, + ) + JSON_DIRECTIVE; + + // json_object mode: forces provider to valid JSON (no tool-args corruption). + // We parse ourselves (instead of langchain strict parser → full control + tolerance). + const llm = getGenerationChat(); // response_format json_object in factory modelKwargs + + const messages: BaseMessage[] = [ + new SystemMessage(systemPrompt), + ...input.history.map((h) => (h.role === "user" ? new HumanMessage(h.content) : new AIMessage(h.content))), + new HumanMessage(input.message), + ]; + + let attempts = 0; + try { + while (attempts <= MAX_ATTEMPTS) { + const ai = (await llm.invoke(messages)) as AIMessage; + const raw = textOf(ai); + const parsed = GenerationSchema.safeParse(safeJson(extractJson(raw))); + if (!parsed.success) { + // Malformed/incomplete JSON → ask for fix and retry. + this.logger.warn(`Could not parse output, retry (${attempts + 1}/${MAX_ATTEMPTS}).`); + if (attempts >= MAX_ATTEMPTS) break; + messages.push(new AIMessage(raw.slice(0, 500))); + messages.push(new HumanMessage("Your output was not valid/complete JSON. Produce ONLY complete JSON in the specified schema.")); + attempts++; + continue; + } + const out: Generation = parsed.data; + + const result = await this.graphService.apply(projectId, { + tabId: input.tabId, + mutations: { nodes: (out.nodes ?? []) as any, edges: (out.edges ?? []) as any }, + }); + attempts++; + + if (result.success) { + return { + reply: out.summary || "Architecture created successfully.", + applied: { idMap: result.idMap, nodeCount: result.nodeCount, edgeCount: result.edgeCount }, + attempts, + }; + } + + // Rule violation → self-correction: return violations, request fixed JSON. + if (attempts > MAX_ATTEMPTS) break; + messages.push(new AIMessage(JSON.stringify({ nodes: out.nodes, edges: out.edges }))); + messages.push( + new HumanMessage( + `This draft violated Solarch rules:\n${JSON.stringify(result.violations).slice(0, 1000)}\n` + + "Apply the suggestions, then produce the complete fixed JSON again.", + ), + ); + } + + return { + reply: "Could not make the architecture rule-compliant within the maximum number of attempts. Please clarify your request.", + applied: null, + attempts, + }; + } catch (err) { + const msg = (err as Error)?.message ?? ""; + this.logger.error(`AI generation error: ${msg.slice(0, 200)}`); + const corrupt = /delimiter|expecting|json|parse|unexpected|column \d+/i.test(msg); + throw new ServiceUnavailableException({ + code: "ERR_AI_GENERATION_FAILED", + message: corrupt + ? "Could not process the AI output — the model returned a corrupted response. Please try again." + : "AI generation failed. Please try again.", + }); + } + } + + /** Mode dispatcher — agent (tool calling) or instruct (text stream). + * `signal`: when client disconnects, in-flight LLM call + DB writes stop. */ + async *chatStream(projectId: string, input: ChatInput, signal?: AbortSignal): AsyncGenerator { + if (input.mode === "instruct") { + yield* this.chatStreamInstruct(projectId, input, signal); + return; + } + yield* this.chatStreamAgent(projectId, input, signal); + } + + /** Instruct mode — chat about the project. No tools; LLM returns text token-by-token. + * System prompt includes graph snapshot and [[node:ID|name]] markup instructions. + * Frontend renders chunks with typewriter effect, converts markers to NodeChips. */ + async *chatStreamInstruct(projectId: string, input: ChatInput, signal?: AbortSignal): AsyncGenerator { + if (!isGenerationConfigured()) { + yield { type: "error", code: "ERR_AI_NOT_CONFIGURED", message: "AI agent is not configured." }; + return; + } + if (!(await this.projectsRepo.exists(projectId))) { + yield { type: "error", code: "ERR_PROJECT_NOT_FOUND", message: `Project '${projectId}' not found.` }; + return; + } + + const { nodes, edges } = await this.projectsRepo.getGraph(projectId); + const systemPrompt = buildInstructPrompt(nodes, edges); + + // Instruct mode: the chat/instruct tier (resolved per active provider). toolCalling=true + // only to drop response_format=json_object so the model returns free text (not JSON). + const llm = getGenerationChat({ + toolCalling: true, + streaming: true, + tier: "instruct", + }); + + const messages: BaseMessage[] = [ + new SystemMessage(systemPrompt), + ...input.history.map((h) => (h.role === "user" ? new HumanMessage(h.content) : new AIMessage(h.content))), + new HumanMessage(input.message), + ]; + + try { + const stream = await llm.stream(messages, { signal }); + let fullText = ""; + for await (const chunk of stream) { + if (signal?.aborted) return; // client disconnected → stop silently + const delta = typeof chunk.content === "string" ? chunk.content + : Array.isArray(chunk.content) + ? chunk.content.map((c) => (typeof c === "string" ? c : "text" in c ? (c as any).text : "")).join("") + : ""; + if (!delta) continue; + fullText += delta; + yield { type: "text-delta", delta }; + } + yield { + type: "done", + message: fullText, + counts: { nodes: 0, edges: 0 }, + attempts: 1, + }; + } catch (err) { + if (signal?.aborted) { + this.logger.log("Instruct stream cancelled (client disconnected)."); + return; + } + const msg = (err as Error)?.message ?? ""; + this.logger.error(`Instruct stream error: ${msg.slice(0, 200)}`); + yield { type: "error", code: "ERR_AI_GENERATION_FAILED", message: "Could not generate the AI response. Please try again." }; + } + } + + /** Returns CONTRACT gaps in the generated project (reuses codegen contract-lint): + * body-field write endpoint without input DTO, role-required-but-no-auth, route-param + * mismatch, dangling DTO/entity ref. For production-time correction loop. + * Domain Node/Edge → codegen IR input: only type/properties/id are read (position + * irrelevant), so boundary cast is safe. */ + private async contractGaps(projectId: string): Promise { + const [nodes, edges] = await Promise.all([this.nodes.list(projectId), this.edges.list(projectId)]); + return lintContracts(buildCodeGraph(nodes as unknown as StoredNode[], edges as unknown as StoredEdge[])); + } + + /** Streaming agent loop — atomic create_node/create_edge tool calling. + * Yields StreamEvent after each tool execute; encoded to SSE. + * Error handling: HttpException response bodies go back to LLM as ToolMessage + * → LLM self-corrects (ReAct). Frontend never sees errors, + * only final nodes/edges. */ + async *chatStreamAgent(projectId: string, input: ChatInput, signal?: AbortSignal): AsyncGenerator { + if (!isGenerationConfigured()) { + yield { + type: "error", + code: "ERR_AI_NOT_CONFIGURED", + message: "AI agent is not configured (BEDROCK_API_KEY / DEEPSEEK_API_KEY missing).", + }; + return; + } + if (!(await this.projectsRepo.exists(projectId))) { + yield { type: "error", code: "ERR_PROJECT_NOT_FOUND", message: `Project '${projectId}' not found.` }; + return; + } + + const { nodes, edges } = await this.projectsRepo.getGraph(projectId); + const homeTabId = input.tabId ?? (await this.tabs.ensureDefault(projectId)).id; + + let patternHits: PatternSearchHit[] = []; + try { + patternHits = await this.patterns.search(input.message, env.EMBED_TOP_K, env.EMBED_MIN_SCORE); + } catch (e) { + this.logger.warn(`Pattern retrieval skipped: ${(e as Error).message}`); + } + + const systemPrompt = + buildSystemPrompt( + // graphRevision unused in prompt context — placeholder 0. + { project: { id: projectId } as any, nodes, edges, counts: { nodes: nodes.length, edges: edges.length }, graphRevision: 0 }, + patternHits, + ) + STREAMING_DIRECTIVE; + + // Agent mode: the high-capability "agent" tier (resolved per active provider). + const llm = getGenerationChat({ toolCalling: true, tier: "agent" }); + const llmWithTools = llm.bindTools!([ + { name: CREATE_NODE_TOOL_NAME, description: CREATE_NODE_DESCRIPTION, schema: CreateNodeArgsSchema }, + { name: CREATE_EDGE_TOOL_NAME, description: CREATE_EDGE_DESCRIPTION, schema: CreateEdgeArgsSchema }, + // Refactor tools — modify existing graph (not append-only). + { name: GET_NODE_TOOL_NAME, description: GET_NODE_DESCRIPTION, schema: GetNodeArgsSchema }, + { name: UPDATE_NODE_TOOL_NAME, description: UPDATE_NODE_DESCRIPTION, schema: UpdateNodeArgsSchema }, + { name: DELETE_NODE_TOOL_NAME, description: DELETE_NODE_DESCRIPTION, schema: DeleteNodeArgsSchema }, + { name: DELETE_EDGE_TOOL_NAME, description: DELETE_EDGE_DESCRIPTION, schema: DeleteEdgeArgsSchema }, + ]); + + const messages: BaseMessage[] = [ + new SystemMessage(systemPrompt), + ...input.history.map((h) => (h.role === "user" ? new HumanMessage(h.content) : new AIMessage(h.content))), + new HumanMessage(input.message), + ]; + // "Continue": previous run paused at step limit. Agent sees the graph above as + // 'Current Architecture' → do NOT recreate existing nodes, + // only fill gaps + connect orphans. + if (input.continueRun) { + messages.push(new HumanMessage( + "CONTINUE MODE: The previous run hit the step limit and the architecture is incomplete. The CURRENT graph above ALREADY EXISTS in this project — do NOT recreate those nodes/edges. Only add missing nodes/edges and connect orphaned nodes appropriately. When done, write a brief summary.", + )); + } + + const MAX_TURNS = env.AI_MAX_TURNS; // safety ceiling (env, default 250). With batching, typical runs finish in far fewer turns. + const MAX_CORRECTION_ROUNDS = 2; // orphan check re-trigger + const MAX_CONTRACT_ROUNDS = 2; // contract-integrity (contract-lint) re-trigger + let attempts = 0; + let nodeCount = 0; + let edgeCount = 0; + let correctionRounds = 0; + let contractRounds = 0; + // Nodes created in this session (for orphan check) + const createdNodes = new Map(); + // Node endpoints appearing in edges (source+target combined) + const edgeEndpoints = new Set(); + // CIRCUIT BREAKER: impossible/repeated edge attempts must not thrash until MAX_TURNS + // and burn tokens. Same (source|target|kind) once rejected is NEVER retried; + // agent stops when consecutive (8) or total (40) failures exceeded (stuck). + const failedEdgeSigs = new Set(); + let totalToolFailures = 0; + let consecutiveFailures = 0; + let stuck = false; + const MAX_CONSECUTIVE_FAILURES = 8; + const MAX_TOTAL_FAILURES = 40; + + // On terminal failure (MAX_TURNS / exception / correction limit), delete nodes + // still orphaned → no half-finished graph; connected subgraph preserved. + // `self` because `this` is not bound in inline generator. Each delete in try/catch: + // if one fails, stream keeps flowing (catch-inner delegation stays safe). + const self = this; + async function* cleanupOrphans(reason: string): AsyncGenerator { + const orphans = [...createdNodes.values()].filter((n) => !edgeEndpoints.has(n.id)); + for (const o of orphans) { + try { + await self.nodes.delete(projectId, o.id); + } catch (e) { + // Delete failed → do NOT corrupt state (no removed yield, no count decrement): + // removing from frontend cache while node remains in DB creates a 'ghost'. + self.logger.warn(`[orphan-cleanup] deletion skipped ${o.id}: ${(e as Error).message}`); + continue; + } + createdNodes.delete(o.id); + nodeCount = Math.max(0, nodeCount - 1); + yield { type: "removed", data: { id: o.id, kind: "node", reason } }; + } + } + + try { + while (attempts < MAX_TURNS) { + if (signal?.aborted) return; // client disconnected → do not start new LLM turn + attempts++; + const ai = (await llmWithTools.invoke(messages, { signal })) as AIMessage; + const toolCalls = (ai.tool_calls ?? []) as Array<{ id?: string; name: string; args: Record }>; + + if (toolCalls.length === 0) { + // LLM stopped calling tools → orphan check (rule-based safety net) + const orphans = [...createdNodes.values()].filter((n) => !edgeEndpoints.has(n.id)); + if (orphans.length > 0 && correctionRounds < MAX_CORRECTION_ROUNDS) { + correctionRounds++; + this.logger.log( + `[orphan-check] ${orphans.length} orphan nodes detected (round ${correctionRounds}/${MAX_CORRECTION_ROUNDS}). Feeding back to LLM.`, + ); + const orphanContext = buildOrphanContext(orphans); + messages.push(ai); // previous "done" attempt into history + messages.push(new HumanMessage( + `**ORPHAN_CHECK FAIL** — not complete yet.\n\n` + + `The following ${orphans.length} nodes are disconnected (orphan). ` + + `Orphan-node rule requires connecting each to an appropriate node via create_edge:\n\n` + + orphanContext + + `\n\nCreate these connections. IMPORTANT: if a node cannot be connected under architecture rules (edge keeps returning ERR_NOT_WHITELISTED/ERR_EDGE_ALREADY_REJECTED), leave it UNCONNECTED and move to your summary — NEVER retry the same rejected edge. If done, write a brief summary.`, + )); + continue; // agent loop continues, LLM will call tools again + } + + // Still orphans after correction limit → clean up (no half-finished graph). + if (orphans.length > 0) { + this.logger.warn( + `[orphan-check] still ${orphans.length} orphans after ${MAX_CORRECTION_ROUNDS} rounds — cleaning up.`, + ); + yield* cleanupOrphans("orphan-after-correction-limit"); + } + + // ── CONTRACT INTEGRITY (orphan-prevention pattern — prompt alone insufficient): + // if generated graph has contract-lint gaps (body-field write endpoint + // without input DTO, role-required-but-no-auth, route-param mismatch, dangling + // ref) FEED BACK to LLM → create missing DTO + wire to endpoint. Closes + // diagram-AI incomplete-contract at production time (codegen still degrades + // gracefully but this puts typed contract on the diagram). ── + if (contractRounds < MAX_CONTRACT_ROUNDS) { + let gaps: string[] = []; + try { + gaps = await this.contractGaps(projectId); + } catch (e) { + this.logger.warn(`[contract-check] skipped: ${(e as Error).message}`); + } + if (gaps.length > 0) { + contractRounds++; + this.logger.log( + `[contract-check] ${gaps.length} contract gaps (round ${contractRounds}/${MAX_CONTRACT_ROUNDS}). Feeding back to LLM.`, + ); + messages.push(ai); + messages.push(new HumanMessage( + `**CONTRACT_CHECK FAIL** — architecture contract incomplete:\n\n` + + gaps.map((g) => `- ${g}`).join("\n") + + `\n\nFix each gap. If a body-field write endpoint (POST/PUT/PATCH) lacks an input DTO: create a DTO node with appropriate fields via create_node, then read the Controller with get_node and send the FULL Endpoints array via update_node — ` + + `set the endpoint's RequestDTORef to the new DTO's Name. For endpoints requiring a role but not auth, enable RequiresAuth. ` + + `If you cannot fix a gap, leave it and write a brief summary.`, + )); + continue; // agent loop continues → LLM fixes + } + } + + yield { + type: "done", + message: textOf(ai) || "Architecture complete.", + counts: { nodes: nodeCount, edges: edgeCount }, + attempts, + }; + return; + } + + messages.push(ai); + + if (signal?.aborted) return; // client disconnected → skip tool execute = empty DB writes + + for (const call of toolCalls) { + if (signal?.aborted) return; // mid-turn abort → stop remaining tool writes + const callId = call.id ?? `call_${attempts}`; + try { + if (call.name === CREATE_NODE_TOOL_NAME) { + const args = CreateNodeArgsSchema.parse(call.args); + const node = await this.nodes.create(projectId, { + type: args.type as NodeKind, + projectId, + position: { x: 0, y: 0 }, // frontend arrange writes real position + properties: args.properties, + homeTabId, + } as any); + nodeCount++; + consecutiveFailures = 0; + const nodeName = extractNodeName(node.type, node.properties as Record); + createdNodes.set(node.id, { id: node.id, type: node.type, name: nodeName }); + yield { type: "node", data: node }; + const warnings = computeProjectWarnings(createdNodes, edgeEndpoints, nodeCount, edgeCount); + messages.push(new ToolMessage({ + content: JSON.stringify({ ok: true, id: node.id, type: node.type, ...(warnings && { warnings }) }), + tool_call_id: callId, + })); + } else if (call.name === CREATE_EDGE_TOOL_NAME) { + const args = CreateEdgeArgsSchema.parse(call.args); + const sig = `${args.sourceNodeId}|${args.targetNodeId}|${args.kind}`; + // Previously REJECTED identical edge → do NOT hit DB/Rules eval; + // tell LLM firmly "do not retry" + count failure (token savings). + if (failedEdgeSigs.has(sig)) { + totalToolFailures++; consecutiveFailures++; + messages.push(new ToolMessage({ + content: JSON.stringify({ ok: false, code: "ERR_EDGE_ALREADY_REJECTED", message: "This connection (same source/target/kind) was already rejected and violates the architecture rules. DO NOT RETRY — create a different connection or leave this node unconnected." }), + tool_call_id: callId, + })); + continue; + } + const edge = await this.edges.create(projectId, { + projectId, + sourceNodeId: args.sourceNodeId, + targetNodeId: args.targetNodeId, + kind: args.kind as EdgeKind, + properties: { IsAsync: false, ...(args.label ? { Label: args.label } : {}) }, + } as any); + edgeCount++; + consecutiveFailures = 0; + edgeEndpoints.add(edge.sourceNodeId); + edgeEndpoints.add(edge.targetNodeId); + yield { type: "edge", data: edge }; + const warnings = computeProjectWarnings(createdNodes, edgeEndpoints, nodeCount, edgeCount); + messages.push(new ToolMessage({ + content: JSON.stringify({ ok: true, id: edge.id, ...(warnings && { warnings }) }), + tool_call_id: callId, + })); + } else if (call.name === GET_NODE_TOOL_NAME) { + // Read-only — show current properties before editing. + const args = GetNodeArgsSchema.parse(call.args); + const node = await this.nodes.getById(projectId, args.nodeId); + consecutiveFailures = 0; + messages.push(new ToolMessage({ + content: JSON.stringify({ ok: true, id: node.id, type: node.type, version: node.version, properties: node.properties }), + tool_call_id: callId, + })); + } else if (call.name === UPDATE_NODE_TOOL_NAME) { + // Modify existing node (rename / field / array). Merge + full validation in service. + const args = UpdateNodeArgsSchema.parse(call.args); + const node = await this.nodes.applyPropertiesPatch(projectId, args.nodeId, args.properties); + consecutiveFailures = 0; + // Refresh name tracking if a session-created node was updated. + if (createdNodes.has(node.id)) { + createdNodes.set(node.id, { id: node.id, type: node.type, name: extractNodeName(node.type, node.properties as Record) }); + } + yield { type: "node", data: node }; // frontend upserts node + messages.push(new ToolMessage({ + content: JSON.stringify({ ok: true, id: node.id, version: node.version }), + tool_call_id: callId, + })); + } else if (call.name === DELETE_NODE_TOOL_NAME) { + const args = DeleteNodeArgsSchema.parse(call.args); + await this.nodes.delete(projectId, args.nodeId); + consecutiveFailures = 0; + if (createdNodes.has(args.nodeId)) { + createdNodes.delete(args.nodeId); + nodeCount = Math.max(0, nodeCount - 1); + } + yield { type: "removed", data: { id: args.nodeId, kind: "node", reason: "ai-delete" } }; + messages.push(new ToolMessage({ + content: JSON.stringify({ ok: true, id: args.nodeId }), + tool_call_id: callId, + })); + } else if (call.name === DELETE_EDGE_TOOL_NAME) { + const args = DeleteEdgeArgsSchema.parse(call.args); + await this.edges.delete(projectId, args.edgeId); + consecutiveFailures = 0; + yield { type: "removed", data: { id: args.edgeId, kind: "edge", reason: "ai-delete" } }; + messages.push(new ToolMessage({ + content: JSON.stringify({ ok: true, id: args.edgeId }), + tool_call_id: callId, + })); + } else { + messages.push(new ToolMessage({ + content: JSON.stringify({ ok: false, code: "ERR_UNKNOWN_TOOL", message: `Unknown tool: ${call.name}` }), + tool_call_id: callId, + })); + } + } catch (err) { + // INFRA TERMINAL ERROR: if Neo4j driver closed (hot-reload / deploy + // SIGTERM → driver.close) this error CANNOT be fixed by LLM — pool closed. + // Counting it as ERR_INTERNAL retry and saying "fix" burns 8-12 LLM turns + tokens, + // then hits circuit-breaker; cleanup also cannot write to same closed pool. Stop + // immediately, cleanly: partial graph preserved (same as abort contract), no futile cleanup. + if (isBackendUnavailable(err)) { + this.logger.warn( + `[backend-unavailable] database unreachable during ${call.name} (may be shutting down) — agent stopped immediately; partial graph preserved.`, + ); + yield { + type: "error", + code: "ERR_BACKEND_UNAVAILABLE", + message: + "The backend is restarting or unavailable. The partial architecture was preserved — please retry in a moment.", + }; + return; + } + // HttpException → response body'i LLM'e geri ver (ReAct self-correct) + const errBody = httpExceptionBody(err); + totalToolFailures++; consecutiveFailures++; + // Record rejected edge signature → same edge never hits DB/Rules again. + if (call.name === CREATE_EDGE_TOOL_NAME) { + const a = call.args as { sourceNodeId?: string; targetNodeId?: string; kind?: string }; + if (a.sourceNodeId && a.targetNodeId && a.kind) failedEdgeSigs.add(`${a.sourceNodeId}|${a.targetNodeId}|${a.kind}`); + } + this.logger.warn(`Tool ${call.name} failed: ${errBody.code ?? "unknown"} (total ${totalToolFailures}, consecutive ${consecutiveFailures}) — let the LLM attempt a correction.`); + messages.push(new ToolMessage({ + content: JSON.stringify({ ok: false, ...errBody }), + tool_call_id: callId, + })); + } + } + + // CIRCUIT BREAKER: too many (consecutive or total) rule violations/failures → + // do not keep trying impossible orphans until MAX_TURNS; stop, preserve valid graph. + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES || totalToolFailures >= MAX_TOTAL_FAILURES) { + this.logger.warn( + `[circuit-breaker] ${totalToolFailures} tool failures (consecutive ${consecutiveFailures}) — stopping agent (stuck).`, + ); + stuck = true; + break; + } + } + + if (stuck) { + // Rule-violation budget (illegal edge thrash): continuing would thrash again → + // clean orphans + graceful done (NOT resumable). + yield* cleanupOrphans("rule-failure-budget"); + yield { + type: "done", + message: "Architecture created. Some connections could not be made because they violate the architecture rules and were skipped.", + counts: { nodes: nodeCount, edges: edgeCount }, + attempts, + }; + return; + } + // STEP LIMIT (MAX_TURNS): work unfinished but ceiling reached. Do NOT clean orphans — + // partial architecture preserved; "Continue" (continueRun) lets agent see current graph and + // resume. paused event shows "Continue" button in frontend. + yield { + type: "paused", + code: "MAX_TURNS_REACHED", + message: `A maximum of ${MAX_TURNS} steps are generated per run. The architecture was partially created — use "Continue" to resume from where it stopped.`, + counts: { nodes: nodeCount, edges: edgeCount }, + attempts, + }; + } catch (err) { + if (signal?.aborted) { + this.logger.log("Agent stream aborted (client disconnected) — the partial graph remains saved."); + return; + } + // Infra terminal error (closed pool) may also be caught here (e.g. non-LLM + // Neo4j call). Cleanup futile (pool closed) → DO NOT attempt; clear signal, keep partial graph. + if (isBackendUnavailable(err)) { + this.logger.warn( + "[backend-unavailable] agent stream — database unreachable (may be shutting down); partial graph preserved, cleanup skipped.", + ); + yield { + type: "error", + code: "ERR_BACKEND_UNAVAILABLE", + message: + "The backend is restarting or unavailable. The partial architecture was preserved — please retry in a moment.", + }; + return; + } + const msg = (err as Error)?.message ?? ""; + this.logger.error(`Stream generation error: ${msg.slice(0, 200)}`); + // No half-finished graph — clean orphans. cleanupOrphans swallows each + // delete internally; still defensive try/catch (second exception must not + // block error event). + try { + yield* cleanupOrphans("agent-exception"); + } catch (e) { + this.logger.warn(`[orphan-cleanup] cleanup skipped during error: ${(e as Error).message}`); + } + yield { + type: "error", + code: "ERR_AI_GENERATION_FAILED", + message: "Unexpected error during AI generation. Unconnectable nodes were cleaned up; the connected subgraph was preserved.", + }; + } + } +} + +/** System prompt appendix for chatStream — shapes agent loop behavior. */ +const STREAMING_DIRECTIVE = ` + +## STREAMING AGENT BEHAVIOR (REQUIRED) +In this mode the architecture is produced in **batched turns**. Be efficient: do as much work per turn as possible. +1. **CALL MULTIPLE TOOLS IN THE SAME TURN (batch — VERY IMPORTANT).** Create related nodes in one turn via parallel create_node calls (e.g. 8-12 nodes per turn). Save returned IDs. Do not call one-by-one; that is slow and burns the turn limit. +2. Once nodes exist with IDs in hand, create edges **in bulk** too: call as many create_edge as possible in one turn. (Each edge's source+target must already exist → edges come in turns AFTER their nodes.) +3. If a tool returns { ok: false, code, message, suggestion }: read the suggestion, fix, **retry the same tool**. +4. After all required nodes and edges are created, write a brief summary for the user (1-2 sentences, respond in English — the product UI is English) — this final message must NOT be a tool call, text only. +5. Do not specify position; backend defaults, frontend auto-layouts. + +## MODIFY EXISTING GRAPH (REFACTOR) +Nodes/edges listed in 'Current Canvas State' ALREADY EXIST. If the user wants a CHANGE (rename, delete, edit a field/array, rewire) do NOT recreate them — use these tools: +- **update_node(nodeId, properties)** — modify an existing node (rename, description/flag, edit an array). Send only changed top-level fields; merged onto existing properties. To edit an ARRAY field (Columns/Endpoints/Methods/Fields) FIRST read the full array with \`get_node\`, then send the FULL array (arrays are replaced, not appended). +- **get_node(nodeId)** — read a node's full properties before editing. +- **delete_node(nodeId)** — delete a node (and its edges). +- **delete_edge(edgeId)** — delete a connection. To REROUTE a connection: \`delete_edge(oldId)\` + \`create_edge(new endpoints)\`. +Node ids and edge ids are given in 'Current Canvas State' — use them, do not invent. When done, write a brief summary without tool calls. + +## ORPHAN NODE RULE (VERY IMPORTANT) +**NO node may remain disconnected (orphan).** Every node you create must connect to at least one other node via an edge. Track created node IDs — do not say "done" until each has at least one create_edge. + +### Correct-direction connection patterns by node type (passive node = edge TARGET, not source): +- **DTO** (target): \`create_edge(source=Controller|Service, target=DTO, kind=USES)\` — request/response payload. DTO is source only in \`DTO→HAS→DTO\` (nested) and \`DTO→USES→Enum\`. +- **Enum** (target): \`create_edge(source=Model|DTO|Table, target=Enum, kind=USES)\`. Enum is NEVER a source. +- **Exception** (target): \`create_edge(source=Service|Controller|Repository, target=Exception, kind=THROWS)\`. +- **EnvironmentVariable** (target): \`create_edge(source=Service, target=EnvironmentVariable, kind=READS_CONFIG)\`. +- **Cache** (target): \`create_edge(source=Service, target=Cache, kind=CACHES_IN)\`. +- **View** (target): \`create_edge(source=Repository, target=View, kind=QUERIES)\`. +- **UIComponent** (target): \`create_edge(source=FrontendApp, target=UIComponent, kind=HAS)\`. +- **Middleware** (source): \`create_edge(source=Middleware, target=Controller, kind=ROUTES_TO)\`. +- **Repository**: target → \`create_edge(source=Service, target=Repository, kind=CALLS)\`; source → \`(source=Repository, target=Table, kind=QUERIES|WRITES)\`, \`(source=Repository, target=Model, kind=USES|RETURNS)\`. +- **Model**: target → \`create_edge(source=Service, target=Model, kind=USES)\`; source → \`Model→USES→Table\`, \`Model→USES→Enum\`, \`Model→HAS|EXTENDS→Model\`. + +If you apply these patterns in the WRONG direction the edge is rejected with \`ERR_NOT_WHITELISTED\`. + +### FINAL CHECK (required) +Before writing "done": for every node ID you created, did you call at least one create_edge? If not → create missing edges now. + +## WARNINGS IN TOOL RESPONSES (TRACK CONTINUOUSLY) +create_node and create_edge results may include a \`warnings\` field: +\`\`\`json +{ + "ok": true, "id": "...", "type": "DTO", + "warnings": { + "status": "8 node, 5 edge — 3 nodes still orphan. Add to your todo list, connect when you can.", + "pendingOrphans": [ + { "id": "abc-123", "type": "Middleware", "name": "JwtAuth", "hint": "create_edge(source=Middleware, target=Controller, kind=ROUTES_TO)" }, + ... + ] + } +} +\`\`\` + +**Read these warnings after every tool call.** Put \`pendingOrphans\` at the top of your todo list. Connect them at the next opportunity (when you create a related node or directly). Do not forget old orphans while creating new nodes — they are **priority**.`; + +/** Thrown when Neo4j driver closed (deploy SIGTERM / dev hot-reload → driver.close). + * This error is NOT retryable and CANNOT be fixed by LLM — pool closed. + * Tool loop must stop immediately instead of burning tokens on "fix" turns. */ +function isBackendUnavailable(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return /pool is closed|driver is closed|connection pool is closed|driver has been closed/i.test(msg); +} + +/** Extract response body (code, message, suggestion, ...) from NestJS HttpException. */ +function httpExceptionBody(err: unknown): Record { + if (err instanceof HttpException) { + const resp = err.getResponse(); + if (typeof resp === "object" && resp !== null) return resp as Record; + return { code: "ERR_HTTP", message: String(resp) }; + } + if (err instanceof Error) return { code: "ERR_INTERNAL", message: err.message }; + return { code: "ERR_UNKNOWN", message: String(err) }; +} + +/** Name field key from node type — same NAME_KEYS order as frontend nameOf. */ +const NAME_KEYS_BY_TYPE: Partial> = { + Table: "TableName", DTO: "Name", Model: "ClassName", Enum: "Name", View: "ViewName", + Service: "ServiceName", Worker: "WorkerName", EventHandler: "HandlerName", + Controller: "ControllerName", MessageQueue: "QueueName", + Repository: "RepositoryName", Cache: "CacheName", ExternalService: "Name", + FrontendApp: "AppName", UIComponent: "ComponentName", + Middleware: "MiddlewareName", + EnvironmentVariable: "Key", Exception: "ExceptionName", + Module: "ModuleName", + APIGateway: "GatewayName", Orchestrator: "OrchestratorName", +}; + +function extractNodeName(type: string, properties: Record): string { + const key = NAME_KEYS_BY_TYPE[type]; + if (key && typeof properties[key] === "string") return properties[key] as string; + // Fallback — herhangi bir string field + for (const k of ["Name", "TableName", "ServiceName", "ControllerName", "ClassName"]) { + if (typeof properties[k] === "string") return properties[k] as string; + } + return `(${type})`; +} + +/** Context sent to LLM for orphan node list — suggests connection pattern by type. */ +// Passive nodes are edge TARGETs — hints match whitelist direction exactly (reverse +// direction yields ERR_NOT_WHITELISTED). source/target explicitly stated. +const ORPHAN_HINTS: Partial> = { + DTO: "DTO is TARGET: create_edge(source=Controller or Service, target=DTO, kind=USES). DTO is source only in DTO→USES→Enum, DTO→HAS→DTO.", + Middleware: "Middleware is SOURCE: create_edge(source=Middleware, target=Controller, kind=ROUTES_TO).", + Enum: "Enum is TARGET (never source): create_edge(source=Model or DTO or Table, target=Enum, kind=USES).", + Exception: "Exception is TARGET: create_edge(source=Service or Controller or Repository, target=Exception, kind=THROWS).", + EnvironmentVariable: "EnvironmentVariable is TARGET: create_edge(source=Service, target=EnvironmentVariable, kind=READS_CONFIG).", + Cache: "Cache is TARGET: create_edge(source=Service, target=Cache, kind=CACHES_IN).", + Repository: "Repository is BOTH target AND source: create_edge(source=Service, target=Repository, kind=CALLS) + create_edge(source=Repository, target=Table, kind=QUERIES or WRITES).", + UIComponent: "UIComponent is TARGET: create_edge(source=FrontendApp, target=UIComponent, kind=HAS).", + Model: "Model: create_edge(source=Service, target=Model, kind=USES) (Model target) + Model→USES→Table, Model→USES→Enum (Model source).", + View: "View is TARGET: create_edge(source=Repository, target=View, kind=QUERIES).", +}; + +function buildOrphanContext(orphans: Array<{ id: string; type: string; name: string }>): string { + return orphans + .map((o) => { + const hint = ORPHAN_HINTS[o.type] ?? "Create an edge to a sensible node (CALLS, USES, HAS, etc.)."; + return `- **${o.type}** "${o.name}" (id: ${o.id}) → ${hint}`; + }) + .join("\n"); +} + +/** Instruct mode system prompt — graph snapshot + markup instructions. */ +function buildInstructPrompt( + nodes: Array<{ id: string; type: string; properties?: Record }>, + edges: Array<{ id: string; sourceNodeId: string; targetNodeId: string; kind: string }>, +): string { + const nodeSnapshot = nodes.map((n) => ({ + id: n.id, + type: n.type, + name: extractNodeName(n.type, (n.properties ?? {}) as Record), + })); + const edgeSnapshot = edges.map((e) => ({ + id: e.id, + kind: e.kind, + source: e.sourceNodeId, + target: e.targetNodeId, + })); + + return `You are Solarch's lead software architect. You answer questions about the user's current architecture graph clearly, professionally, and concisely (respond in English — the product UI is English). + +**NEVER CREATE OR MODIFY NODES.** Only explain the existing graph and guide the user. + +## NODE/EDGE REFERENCE MARKUP (REQUIRED) +When referring to a node use this format: \`[[node:NODE_ID|Display Name]]\` +Examples: +- "[[node:abc-12345|Users table]] stores user data." +- "The request first hits [[node:def-67890|AuthController]], then [[node:ghi-13579|AuthService]]." + +When referring to an edge: \`[[edge:EDGE_ID|short description]]\` +Example: "Called via [[edge:xyz-456|CALLS connection]]." + +**IDs are given in the snapshot below — NEVER invent them, always use these IDs.** Always use markup instead of plain names so the frontend can convert to chips and highlight on the canvas. + +## CURRENT ARCHITECTURE SNAPSHOT + +### Nodes (${nodes.length}) +${JSON.stringify(nodeSnapshot, null, 2)} + +### Edges (${edges.length}) +${JSON.stringify(edgeSnapshot, null, 2)} + +## STYLE +- Short, clear, no jargon (respond in English — the product UI is English). +- You may use markdown headings/lists but don't overdo it. +- Your answer should flow; markers must not break the text (chips appear inline).`; +} + +/** Project warning — appended to each tool result. LLM reads this and adds to TODO list + * with priority. Proactive not reactive: situational awareness each step instead of + * accumulating orphans. */ +interface ProjectWarnings { + status: string; // "10 node, 6 edge — 4 orphans remaining" + pendingOrphans: Array<{ id: string; type: string; name: string; hint: string }>; +} + +function computeProjectWarnings( + createdNodes: Map, + edgeEndpoints: Set, + nodeCount: number, + edgeCount: number, +): ProjectWarnings | null { + if (createdNodes.size === 0) return null; + const orphans = [...createdNodes.values()].filter((n) => !edgeEndpoints.has(n.id)); + + if (orphans.length === 0) { + // All nodes connected — brief status only (LLM knows it's going well) + return { + status: `${nodeCount} node, ${edgeCount} edge — all nodes connected, looking good.`, + pendingOrphans: [], + }; + } + + return { + status: `${nodeCount} node, ${edgeCount} edge — ${orphans.length} nodes still orphan. Add to your todo list, connect when you can.`, + pendingOrphans: orphans.map((o) => ({ + id: o.id, + type: o.type, + name: o.name, + hint: ORPHAN_HINTS[o.type] ?? "Create an edge to a sensible node (CALLS, USES, HAS, etc.).", + })), + }; +} diff --git a/apps/server/src/ai/dto/chat-response.dto.ts b/apps/server/src/ai/dto/chat-response.dto.ts new file mode 100644 index 0000000..594c6e8 --- /dev/null +++ b/apps/server/src/ai/dto/chat-response.dto.ts @@ -0,0 +1,30 @@ +import type { SuccessEnvelope } from "../../common/envelope"; +import type { Node } from "../../nodes/schemas"; +import type { Edge } from "../../edges/schemas/edge.schema"; + +export interface ChatResult { + /** AI reply text returned to the user. */ + reply: string; + /** Result when architecture was applied (idMap + counts); null if not applied. */ + applied: { + idMap: Record; + nodeCount: number; + edgeCount: number; + } | null; + /** Number of tool-call attempts (ReAct loop). */ + attempts: number; +} + +export type ChatResponse = SuccessEnvelope; + +/** chatStream() yield events — encoded to SSE. + * Frontend EventSource event type: "node" | "edge" | "text-delta" | "done" | "error". */ +export type StreamEvent = + | { type: "node"; data: Node } + | { type: "edge"; data: Edge } + | { type: "text-delta"; delta: string } // instruct mode incremental text + | { type: "removed"; data: { id: string; kind: "node" | "edge"; reason: string } } // terminal rollback: orphan cleanup + | { type: "done"; message: string; counts: { nodes: number; edges: number }; attempts: number } + // Step limit (MAX_TURNS) reached, work unfinished — orphans NOT cleaned; resumes via "Continue". + | { type: "paused"; code: string; message: string; counts: { nodes: number; edges: number }; attempts: number } + | { type: "error"; code: string; message: string }; diff --git a/apps/server/src/ai/dto/chat.dto.ts b/apps/server/src/ai/dto/chat.dto.ts new file mode 100644 index 0000000..b6d028b --- /dev/null +++ b/apps/server/src/ai/dto/chat.dto.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { createZodDto } from "nestjs-zod"; + +// Input upper bounds — narrows DoS + cost + prompt-injection surface. +export const MAX_MESSAGE_CHARS = 8000; +export const MAX_HISTORY_ITEMS = 50; + +export const ChatMessageSchema = z.object({ + role: z.enum(["user", "assistant"]), + content: z.string().min(1).max(MAX_MESSAGE_CHARS), +}).strict(); + +export const ChatSchema = z.object({ + message: z.string().min(1).max(MAX_MESSAGE_CHARS), + history: z.array(ChatMessageSchema).max(MAX_HISTORY_ITEMS).default([]), + tabId: z.string().uuid().optional(), // home tab for generated nodes + /** agent: produces architecture via tool calling; instruct: explanation only (text stream + [[node:ID|name]] markup). */ + mode: z.enum(["agent", "instruct"]).default("agent"), + /** "Continue": previous run paused at step limit (MAX_TURNS) → + * agent sees current graph and fills gaps, does NOT recreate existing nodes. */ + continueRun: z.boolean().default(false), +}).strict(); + +export type ChatInput = z.infer; + +export class ChatDto extends createZodDto(ChatSchema) {} diff --git a/apps/server/src/ai/prompts/instruct-prompt.ts b/apps/server/src/ai/prompts/instruct-prompt.ts new file mode 100644 index 0000000..a0b229b --- /dev/null +++ b/apps/server/src/ai/prompts/instruct-prompt.ts @@ -0,0 +1,33 @@ +/** Instruct mode system prompt — graph snapshot + reference markup (read-only Q&A). */ +export function buildInstructPrompt( + nodeSnapshot: Array<{ id: string; type: string; name: string }>, + edgeSnapshot: Array<{ id: string; kind: string; source: string; target: string }>, +): string { + return `You are Solarch's lead software architect. You answer questions about the user's current architecture graph clearly, professionally, and concisely (respond in English). + +**DO NOT create or mutate nodes/edges.** Explain and guide using the existing graph only. + +## NODE/EDGE REFERENCE MARKUP (REQUIRED) +When mentioning a node use: \`[[node:NODE_ID|Display Name]]\` +Examples: +- "[[node:abc-12345|Users table]] stores user records." +- "Requests hit [[node:def-67890|AuthController]] first, then [[node:ghi-13579|AuthService]]." + +When mentioning an edge use: \`[[edge:EDGE_ID|short description]]\` +Example: "Called via [[edge:xyz-456|CALLS link]]." + +**Use IDs from the snapshot below — never invent IDs.** Always use markup (not plain names) so the frontend can render chips and highlight on the canvas. + +## CURRENT ARCHITECTURE SNAPSHOT + +### Nodes (${nodeSnapshot.length}) +${JSON.stringify(nodeSnapshot, null, 2)} + +### Edges (${edgeSnapshot.length}) +${JSON.stringify(edgeSnapshot, null, 2)} + +## STYLE +- Short, clear, minimal jargon. +- Markdown headings/lists are fine but keep it readable. +- Markup should flow inline with the prose (chips render inline).`; +} diff --git a/apps/server/src/ai/prompts/orphan-hints.ts b/apps/server/src/ai/prompts/orphan-hints.ts new file mode 100644 index 0000000..e7d043f --- /dev/null +++ b/apps/server/src/ai/prompts/orphan-hints.ts @@ -0,0 +1,40 @@ +/** Edge-direction hints for orphan nodes — must match whitelist (passive node = TARGET). */ +export const ORPHAN_HINTS: Partial> = { + DTO: "DTO is TARGET: create_edge(source=Controller or Service, target=DTO, kind=USES). DTO is source only for DTO→USES→Enum, DTO→HAS→DTO.", + Middleware: "Middleware is SOURCE: create_edge(source=Middleware, target=Controller, kind=ROUTES_TO).", + Enum: "Enum is TARGET (never source): create_edge(source=Model or DTO or Table, target=Enum, kind=USES).", + Exception: "Exception is TARGET: create_edge(source=Service or Controller or Repository, target=Exception, kind=THROWS).", + EnvironmentVariable: "EnvironmentVariable is TARGET: create_edge(source=Service, target=EnvironmentVariable, kind=READS_CONFIG).", + Cache: "Cache is TARGET: create_edge(source=Service, target=Cache, kind=CACHES_IN).", + Repository: "Repository is both target and source: create_edge(source=Service, target=Repository, kind=CALLS) + create_edge(source=Repository, target=Table, kind=QUERIES or WRITES).", + UIComponent: "UIComponent is TARGET: create_edge(source=FrontendApp, target=UIComponent, kind=HAS).", + Model: "Model: create_edge(source=Service, target=Model, kind=USES) (target) + Model→USES→Table, Model→USES→Enum (source).", + View: "View is TARGET: create_edge(source=Repository, target=View, kind=QUERIES).", +}; + +export const DEFAULT_ORPHAN_HINT = + "Create an edge to a logical peer (CALLS, USES, HAS, etc.)."; + +export function orphanHintFor(type: string): string { + return ORPHAN_HINTS[type] ?? DEFAULT_ORPHAN_HINT; +} + +export function buildOrphanContext( + orphans: Array<{ id: string; type: string; name: string }>, +): string { + return orphans + .map((o) => `- **${o.type}** "${o.name}" (id: ${o.id}) → ${orphanHintFor(o.type)}`) + .join("\n"); +} + +export function allNodesConnectedStatus(nodeCount: number, edgeCount: number): string { + return `${nodeCount} nodes, ${edgeCount} edges — all nodes connected, good progress.`; +} + +export function orphanWarningStatus( + nodeCount: number, + edgeCount: number, + orphanCount: number, +): string { + return `${nodeCount} nodes, ${edgeCount} edges — ${orphanCount} nodes still orphan. Add to your TODO list and connect when possible.`; +} diff --git a/apps/server/src/ai/prompts/streaming-directive.ts b/apps/server/src/ai/prompts/streaming-directive.ts new file mode 100644 index 0000000..6fa44f9 --- /dev/null +++ b/apps/server/src/ai/prompts/streaming-directive.ts @@ -0,0 +1,55 @@ +/** Addendum for chatStream — shapes the agent-loop behavior (batch tools, refactor, orphans). */ +export const STREAMING_DIRECTIVE = ` + +## STREAMING AGENT BEHAVIOR (REQUIRED) +In this mode architecture is produced in **batched turns**. Be efficient: do as much as possible each turn. +1. **CALL MULTIPLE TOOLS IN THE SAME TURN (batch — CRITICAL).** Create related nodes in one turn with parallel create_node calls (e.g. 8–12 nodes per turn). Store returned IDs. Do not call one-by-one — that is slow and burns the turn budget. +2. Once nodes exist with IDs, create edges **in bulk**: as many create_edge calls as fit in one turn. (Each edge needs both endpoints to exist already → edges come in turns **after** their nodes.) +3. If a tool returns { ok: false, code, message, suggestion }: read suggestion, fix the call, **retry the same tool**. +4. After all required nodes and edges exist, write a short summary for the user (1–2 sentences, respond in English) — this final message must NOT be a tool call, text only. +5. Do not set position; the backend defaults it and the frontend auto-layouts. + +## REFACTORING THE EXISTING GRAPH +Nodes/edges listed under 'Current Canvas State' **already exist**. If the user asks for a CHANGE (rename, delete, edit a field/array, rewire a connection), do NOT recreate them — use: +- **update_node(nodeId, properties)** — patch an existing node (rename, description/flags, array edits). Send only changed top-level fields; they merge into existing properties. To edit an ARRAY field (Columns/Endpoints/Methods/Fields), first \`get_node\` for the full array, then send the **complete** array (arrays replace, not append). +- **get_node(nodeId)** — read full properties before editing. +- **delete_node(nodeId)** — remove a node (and its edges). +- **delete_edge(edgeId)** — remove a connection. To **rewire**: \`delete_edge(oldId)\` + \`create_edge(new endpoints)\`. +Use the IDs from 'Current Canvas State' — never invent IDs. When done, write a short summary without tool calls. + +## NO ORPHAN NODES (CRITICAL) +**Every node must have at least one edge** to another node. Track created node IDs — do not say "done" until each has at least one create_edge. + +### Correct edge direction by node type (passive node = edge **TARGET**, not source): +- **DTO** (target): \`create_edge(source=Controller|Service, target=DTO, kind=USES)\`. DTO is source only for \`DTO→HAS→DTO\` (nested) and \`DTO→USES→Enum\`. +- **Enum** (target): \`create_edge(source=Model|DTO|Table, target=Enum, kind=USES)\`. Enum is never a source. +- **Exception** (target): \`create_edge(source=Service|Controller|Repository, target=Exception, kind=THROWS)\`. +- **EnvironmentVariable** (target): \`create_edge(source=Service, target=EnvironmentVariable, kind=READS_CONFIG)\`. +- **Cache** (target): \`create_edge(source=Service, target=Cache, kind=CACHES_IN)\`. +- **View** (target): \`create_edge(source=Repository, target=View, kind=QUERIES)\`. +- **UIComponent** (target): \`create_edge(source=FrontendApp, target=UIComponent, kind=HAS)\`. +- **Middleware** (source): \`create_edge(source=Middleware, target=Controller, kind=ROUTES_TO)\`. +- **Repository**: target ← \`create_edge(source=Service, target=Repository, kind=CALLS)\`; source → \`(source=Repository, target=Table, kind=QUERIES|WRITES)\`, \`(source=Repository, target=Model, kind=USES|RETURNS)\`. +- **Model**: target ← \`create_edge(source=Service, target=Model, kind=USES)\`; source → \`Model→USES→Table\`, \`Model→USES→Enum\`, \`Model→HAS|EXTENDS→Model\`. + +Wrong direction → edge rejected with \`ERR_NOT_WHITELISTED\`. + +### FINAL CHECK (required) +Before your "done" message: for every node ID you created, did you call at least one create_edge? If not → create missing edges now. + +## TOOL RESPONSE WARNINGS (TRACK CONTINUOUSLY) +create_node and create_edge results may include a \`warnings\` field: +\`\`\`json +{ + "ok": true, "id": "...", "type": "DTO", + "warnings": { + "status": "8 nodes, 5 edges — 3 nodes still orphan. Add to your TODO list and connect when possible.", + "pendingOrphans": [ + { "id": "abc-123", "type": "Middleware", "name": "JwtAuth", "hint": "create_edge(source=Middleware, target=Controller, kind=ROUTES_TO)" }, + ... + ] + } +} +\`\`\` + +**Read warnings after every tool call.** Put \`pendingOrphans\` at the top of your TODO list. Connect them at the next opportunity (when you create related nodes or directly). Do not forget older orphans while creating new nodes — they are **priority**.`; diff --git a/apps/server/src/ai/prompts/system-prompt.spec.ts b/apps/server/src/ai/prompts/system-prompt.spec.ts new file mode 100644 index 0000000..c37d2d5 --- /dev/null +++ b/apps/server/src/ai/prompts/system-prompt.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { buildSystemPrompt } from "./system-prompt"; +import { WHITELIST } from "../../rules/registry/whitelist"; + +const emptyGraph = { project: {} as any, nodes: [], edges: [], counts: { nodes: 0, edges: 0 } }; + +function isLegal(source: string, edge: string, target: string): boolean { + return WHITELIST.some((r) => { + const ss = Array.isArray(r.source) ? r.source : [r.source]; + const tt = Array.isArray(r.target) ? r.target : [r.target]; + return ss.includes(source as never) && r.edge === edge && tt.includes(target as never); + }); +} + +// Regression: AI agent kept getting ERR_NOT_WHITELISTED on every create_edge because grounding +// (ORPHAN_HINTS/STREAMING/prompt) made passive nodes the SOURCE — whitelist only accepts +// them as TARGET. These tests pin grounding direction to whitelist. +describe("AI grounding direction == whitelist (agent-stuck regression)", () => { + it.each([ + ["Controller", "USES", "DTO"], + ["Service", "USES", "DTO"], + ["Model", "USES", "Enum"], + ["Service", "THROWS", "Exception"], + ["Service", "READS_CONFIG", "EnvironmentVariable"], + ["Service", "CACHES_IN", "Cache"], + ["Repository", "QUERIES", "View"], + ["FrontendApp", "HAS", "UIComponent"], + ["Middleware", "ROUTES_TO", "Controller"], + ["Service", "CALLS", "Repository"], + ])("correct direction is legal: %s -%s-> %s", (s, e, t) => expect(isLegal(s, e, t)).toBe(true)); + + it.each([ + ["DTO", "USES", "Controller"], + ["Enum", "USES", "Table"], + ["Exception", "THROWS", "Service"], + ["EnvironmentVariable", "READS_CONFIG", "Service"], + ["Cache", "CACHES_IN", "Service"], + ])("reversed direction (old bug) is illegal: %s -%s-> %s", (s, e, t) => expect(isLegal(s, e, t)).toBe(false)); +}); + +describe("system prompt grounding (agent-stuck regression)", () => { + const p = buildSystemPrompt(emptyGraph as any); + it("embeds the real whitelist matrix", () => { + expect(p).toContain("LEGAL CONNECTIONS"); + expect(p).toContain("Controller:"); + expect(p).toContain("CALLS → Service"); + }); + it("names atomic tools instead of apply_architecture_graph", () => { + expect(p).toContain("create_node"); + expect(p).toContain("create_edge"); + expect(p).not.toContain("apply_architecture_graph"); + }); +}); + +describe("buildSystemPrompt patterns", () => { + it("omits REFERENCE PATTERNS section when no patterns", () => { + expect(buildSystemPrompt(emptyGraph as any)).not.toContain("REFERENCE PATTERNS"); + }); + + it("injects name + score + structure when patterns present", () => { + const hits = [ + { + score: 0.88, + pattern: { + name: "Layered CRUD", + description: "description", + graph: { nodes: [{ tempId: "t", type: "Controller", properties: {} }], edges: [] }, + }, + }, + ]; + const p = buildSystemPrompt(emptyGraph as any, hits as any); + expect(p).toContain("REFERENCE PATTERNS"); + expect(p).toContain("Layered CRUD"); + expect(p).toContain("0.88"); + expect(p).toContain("Controller"); + }); +}); diff --git a/apps/server/src/ai/prompts/system-prompt.ts b/apps/server/src/ai/prompts/system-prompt.ts new file mode 100644 index 0000000..632415f --- /dev/null +++ b/apps/server/src/ai/prompts/system-prompt.ts @@ -0,0 +1,156 @@ +import type { ProjectGraph } from "../../projects/dto/project-response.dto"; +import type { PatternSearchHit } from "../../patterns/patterns.repository"; +import { WHITELIST } from "../../rules/registry/whitelist"; + +/** Groups the whitelist (default-deny allow-list) by source into a compact + * "legal connections" matrix. Embedded in the prompt so the LLM KNOWS legal + * source→edge→target triples instead of guessing. Single source WHITELIST + * → prompt and enforcement never drift. */ +function formatWhitelistMatrix(): string { + const fmt = (v: string | string[]) => (Array.isArray(v) ? v.join("|") : v); + const bySource = new Map(); + for (const r of WHITELIST) { + const sources = Array.isArray(r.source) ? r.source : [r.source]; + for (const s of sources) { + const arr = bySource.get(s) ?? []; + arr.push(`${r.edge} → ${fmt(r.target)}`); + bySource.set(s, arr); + } + } + return [...bySource.entries()].map(([s, edges]) => `- ${s}: ${edges.join(", ")}`).join("\n"); +} + +const WHITELIST_MATRIX = formatWhitelistMatrix(); + +const BASE_PROMPT = `You are Solarch's **Chief Software Architect**. You design scalable, secure, best-practice microservice/monolith architectures at Google, Netflix, Stripe scale. Your job: turn the user's natural-language request into a complete architecture graph (Node + Edge) that is 100% compliant with Solarch rules. + +**CORE PRINCIPLE:** Never give the user JSON code or hypothetical diagrams. Build the system with ATOMIC tools: first call \`create_node\` for each component (the backend returns the real node ID), then connect with \`create_edge\` using those IDs. There is NO tool that sends the whole graph at once — create nodes and edges one by one. Available tools: \`create_node\`, \`create_edge\`, \`get_node\`, \`update_node\`, \`delete_node\`, \`delete_edge\`. + +## Node Types You May Use (you CANNOT invent new ones) +- **Data:** Table, DTO, Model, Enum, View +- **Business Logic:** Service, Worker, EventHandler, Orchestrator +- **Access:** Controller, APIGateway, MessageQueue +- **Infrastructure:** Repository, Cache, ExternalService +- **Client:** FrontendApp, UIComponent +- **Security/Config:** Middleware, Exception, EnvironmentVariable +- **Structure:** Module + +## Edge Types You May Use +- **Call:** CALLS (sync), REQUESTS (network) +- **Async:** PUBLISHES, SUBSCRIBES +- **DB:** QUERIES (read), WRITES (write) +- **Schema:** USES, HAS, RETURNS, EXTENDS, IMPLEMENTS +- **Other:** CACHES_IN, THROWS, DEPENDS_ON, READS_CONFIG, ROUTES_TO + +## LEGAL CONNECTIONS (Rules Engine — default-deny; create_edge ONLY allows these) +Every connection OUTSIDE the \`source: edge → target\` triples below is rejected with \`ERR_NOT_WHITELISTED\`. **DIRECTION IS CRITICAL:** passive data/infrastructure nodes (DTO, Enum, Exception, Cache, EnvironmentVariable, View, UIComponent) are the TARGET of an edge — they CANNOT be the SOURCE. Correct: \`Controller USES → DTO\`; wrong (reversed): \`DTO USES → Controller\`. + +${WHITELIST_MATRIX} + +Key rules: Frontend → Table FORBIDDEN (via Controller/APIGateway); Controller cannot go directly to Table (\`Controller → CALLS → Service → CALLS → Repository → WRITES/QUERIES → Table\`); data objects (Table/DTO/Enum) cannot initiate actions; DTO must not leak Model. If a node cannot connect per this matrix, leave it UNCONNECTED. + +## SELF-CORRECTION (VERY IMPORTANT) +When a \`create_node\`/\`create_edge\` call returns an error (\`{ ok: false, code, message, suggestion, details }\`): +- NEVER tell the user "the system failed". Read \`details\` (field-level) + \`suggestion\`, fix your call, and call AGAIN. +- **ERR_SCHEMA_INVALID** → node properties schema is wrong (missing required field / wrong field name / wrong enum value). Match the NODE PROPERTIES SCHEMAS below exactly; fix the field in \`details\`. +- **ERR_NOT_WHITELISTED** → edge direction/type is not legal. Pick the correct \`source → edge → target\` triple from the LEGAL CONNECTIONS matrix above (passive node = TARGET). NEVER retry the same rejected edge. + +## WORKFLOW +1. Analyze: what does the user want? +2. Plan: which nodes + how do they connect? (layered architecture + matrix above) +3. Call \`create_node\` for each component, store returned IDs; then \`create_edge\` (correct direction). +4. On success: give the user a short, professional summary (respond in English — the product UI is English). + On failure: apply details/suggestion and call again. + +Fill node properties realistically (sensible Columns on Table, relevant Methods on Service). Avoid unnecessary complexity — if the user wants something simple, do not build a 20-node Saga. + +## NODE PROPERTIES SCHEMAS (MUST MATCH EXACTLY) +**\`Description\` (string) is REQUIRED on ALL nodes.** +**properties schema is STRICT (.strict):** an unknown or misnamed field (e.g. \`IsForeignKey\`, \`primaryKey\`) rejects the ENTIRE node with \`ERR_SCHEMA_INVALID\` — send only listed fields. Enum values are EXACT and CASE-SENSITIVE (e.g. \`DataType: "UUID"\` correct, \`"uuid"\` wrong). Do not omit required booleans (e.g. Column IsPrimaryKey/IsNotNull/IsUnique/AutoIncrement). + +- **Table:** \`{ TableName, Description, Columns: [{ Name, DataType, IsPrimaryKey, IsNotNull, IsUnique, AutoIncrement, Length?, Precision?, Scale?, DefaultValue?, EnumRef?, Comment? }], PrimaryKey?: { Columns: [...] }, ForeignKeys?: [{ Columns: [...], ReferencesTable, ReferencesColumns: [...], OnDelete?, OnUpdate? }], UniqueConstraints?: [{ Columns: [...] }], CheckConstraints?: [{ Expression }], Indexes?: [{ IndexName, Columns: [...], Type?, IsUnique?, IsPartial?, WhereClause? }] }\` + - **DataType ONLY these enums:** \`INT\`, \`BIGINT\`, \`VARCHAR\`, \`TEXT\`, \`BOOLEAN\`, \`DATETIME\`, \`DATE\`, \`UUID\`, \`FLOAT\`, \`DECIMAL\`, \`JSON\`, \`ENUM\` (uppercase!). "integer"/"varchar(255)" WRONG — use "VARCHAR" + separate Length; "DECIMAL" + Precision/Scale. + - All 4 booleans (IsPrimaryKey/IsNotNull/IsUnique/AutoIncrement) REQUIRED on each Column. **IsForeignKey NONE** — FK relationship defined in \`ForeignKeys\` array (OnDelete: CASCADE/RESTRICT/SET_NULL/NO_ACTION). + - Composite PK: use \`PrimaryKey.Columns\`; single-column PK: Column.IsPrimaryKey=true. If \`DataType: "ENUM"\`, set \`EnumRef\` to Enum node Name. +- **DTO:** \`{ Name, Description, Fields: [{ Name, DataType, IsRequired, IsArray, ValidationRules?: [{ Rule, Value? }], DefaultValue?, NestedDTORef?, EnumRef? }] }\` + - **ValidationRules** are structural (NOT string): Rule ∈ \`Min/Max/MinLength/MaxLength/Email/Url/Regex/Pattern/Positive/Negative\`, Value optional (e.g. \`{ Rule: "MinLength", Value: "8" }\`). \`NestedDTORef\` for nested DTO, \`EnumRef\` for enum fields. +- **Model:** \`{ ClassName, Description, TableRef?, Properties: [{ Name, Type, IsNullable?, IsCollection?, RelationType?, RelatedModelRef? }], Methods: [{ MethodName, Visibility?, Parameters?: [{ Name, Type, Optional?, Default? }], ReturnType, IsAsync?, IsStatic? }] }\` + - For relations: \`RelationType\` ∈ \`OneToOne/OneToMany/ManyToOne/ManyToMany\` + \`RelatedModelRef\` (target Model ClassName). \`TableRef\` for corresponding Table. +- **Enum:** \`{ Name, Description, BackingType?: "string"|"int", Values: [{ Key, Value?, Description? }] }\` + - Values are NOT string[] — object array. E.g. \`{ Key: "SHIPPED", Value: "shipped" }\`. If Value omitted, Key is used. +- **View:** \`{ ViewName, Description, Definition, SourceTables: [...], Materialized, Columns?: [{ Name, DataType }], RefreshStrategy?: "onDemand"|"scheduled"|"onChange" }\` +- **Service:** \`{ ServiceName, Description, IsTransactionScoped: bool, Methods: [{ MethodName, Visibility?, Parameters: [{ Name, Type, Optional?, Default?, DtoRef? }], ReturnType, ReturnDtoRef?, IsAsync?, Throws?: [→Exception Name], Description? }], Dependencies?: [{ Kind: "Repository"|"Service"|"Cache"|"ExternalService", Ref }] }\` + - **InputParams NONE** → \`Parameters\`. DI dependencies \`Dependencies[]\` (Kind+Ref). Thrown exceptions \`Throws[]\` (Exception Name). +- **Controller:** \`{ ControllerName, Description, BaseRoute, Version?, Endpoints: [{ HttpMethod, Route, RequestDTORef?, ResponseDTORef?, RequiresAuth, RequiredRoles?, PathParams?: [{Name,Type}], QueryParams?: [{Name,Type,Required?}], StatusCodes?: [{Code,Description?}], MiddlewareRefs?: [→Middleware Name], RateLimit?: {Requests,WindowSeconds}, Description? }] }\` (HttpMethod: GET/POST/PUT/DELETE/PATCH) + - **RequestDTO/ResponseDTO NONE** → \`RequestDTORef\`/\`ResponseDTORef\` (DTO Name). +- **Repository:** \`{ RepositoryName, Description, EntityReference (→Model/Table Name), BaseClass?, IsCached?, CustomQueries?: [{ QueryName, QueryType?: "find"|"findOne"|"aggregate"|"raw"|"custom", Parameters?: [{Name,Type}], ReturnType }] }\` + - **CustomQueries is now OBJECT array** (not string[]): \`[{ QueryName: "findByEmail", QueryType: "findOne", ReturnType: "User" }]\`. +- **Cache:** \`{ CacheName, Description, KeyPattern, TTL_Seconds, Engine, EvictionPolicy?: "LRU"|"LFU"|"FIFO"|"TTL", MaxSizeMB?, Serialization?: "json"|"binary"|"string" }\` (Engine: Redis/Memcached/Memory) +- **MessageQueue:** \`{ QueueName, Description, Type, Provider, MessageFormat (→DTO Name), DeliveryGuarantee?: "at-least-once"|"exactly-once"|"at-most-once", MaxRetries?, DeadLetterQueue?, RetentionSeconds? }\` (Type: Queue/Topic; Provider: RabbitMQ/Kafka/AWS_SQS/Generic) +- **ExternalService:** \`{ ServiceName, Description, BaseURL, AuthType, TimeoutSeconds, Endpoints?: [{ Name, Method, Path }], RetryPolicy?: {MaxRetries,DelaySeconds?}, RateLimit?: {Requests,WindowSeconds}, CircuitBreaker?: {FailureThreshold,ResetSeconds} }\` (AuthType: None/Basic/Bearer/API_Key) +- **FrontendApp:** \`{ AppName, Description, Framework, DeploymentType, StateManagement?: "Redux"|"Zustand"|"Context"|"Pinia"|"Vuex"|"NgRx"|"None", StylingApproach?: "CSS"|"SCSS"|"Tailwind"|"StyledComponents"|"CSSModules", Routes?: [{ Path, ComponentRef? }] }\` (Framework: React/Vue/Angular/Svelte/Vanilla; DeploymentType: SPA/SSR/SSG) +- **UIComponent:** \`{ ComponentName, Description, Props?: [{ Name, Type, Required? }], State?: [{ Name, Type }], Events?: [{ Name, PayloadType? }], ChildComponentRefs?: [→UIComponent Name] }\` +- **Middleware:** \`{ MiddlewareName, Description, AppliesTo: "Global"|"SpecificRoutes", ExecutionOrder, MiddlewareType?: "Auth"|"Logging"|"RateLimit"|"Cors"|"Compression"|"ErrorHandler"|"Custom", Config?: [{Key,Value}] }\` +- **EnvironmentVariable:** \`{ Key, Description, DataType: "String"|"Number"|"Boolean", IsSecret, Environment: ["Dev"|"Staging"|"Prod"], DefaultValue?, IsRequired?, ValidationPattern? }\` (NEVER write secret values, only Key + IsSecret:true) +- **Module:** \`{ ModuleName, Description, StrictBoundaries, ExposedServices?: [→Service Name], Dependencies?: [→Module Name] }\` +- **Worker:** \`{ WorkerName, Description, Schedule (cron), TaskToExecute, TimeoutSeconds, RetryPolicy: { MaxRetries, BackoffStrategy?: "fixed"|"exponential", DelaySeconds? }, Concurrency?, IsEnabled? }\` + - **RetryPolicy is now OBJECT** (not number): \`{ MaxRetries: 3, BackoffStrategy: "exponential" }\`. +- **EventHandler:** \`{ HandlerName, Description, EventName, IsAsync, QueueRef?: (→MessageQueue Name), RetryPolicy?: { MaxRetries, DelaySeconds? }, DeadLetterQueue? }\` +- **APIGateway:** \`{ GatewayName, Description, Provider, AuthMode?: "None"|"JWT"|"OAuth2"|"ApiKey", CorsEnabled?, Routes?: [{ Path, TargetRef (→Controller/Service Name), Methods: [HttpMethod], AuthRequired?, RateLimit?: {Requests,WindowSeconds} }] }\` (Provider: Kong/Nginx/AWS_API_Gateway/...) +- **Orchestrator:** \`{ OrchestratorName, Description, Pattern, Steps?: [{ StepName, ServiceRef (→Service Name), Action, CompensationAction?, OnFailure?: "retry"|"compensate"|"abort" }] }\` (Pattern: Saga/CompensatingTransaction/StateMachine/ProcessManager) +- **Exception:** \`{ ExceptionName, Description, HttpStatusCode, LogSeverity, ErrorCode?, ParentExceptionRef?: (→Exception Name) }\` (LogSeverity: Info/Warning/Error/Critical) + +Unknown or missing fields are rejected by the Rules Engine. Use the correct schema on the first try — do not waste attempts.`; + +export function buildSystemPrompt(graph: ProjectGraph, patterns: PatternSearchHit[] = []): string { + const nodeSummary = + graph.nodes.length === 0 + ? "(Canvas empty — starting from scratch.)" + : graph.nodes + .map((n) => `- ${n.type}: ${firstName(n.properties)} (id: ${n.id})`) + .join("\n"); + const edgeSummary = + graph.edges.length === 0 + ? "(No connections yet.)" + : graph.edges + .map((e) => `- ${e.sourceNodeId} -[${e.kind}]-> ${e.targetNodeId} (id: ${e.id})`) + .join("\n"); + + const patternBlock = + patterns.length === 0 + ? "" + : ` + +## RELEVANT REFERENCE PATTERNS (retrieval) +The proven architecture patterns below are close to the request. If appropriate, produce similar output (do not copy verbatim — adapt, add/remove as needed): +${patterns + .map((h) => { + const g = h.pattern.graph; + const nodeTypes = g.nodes.map((n) => n.type).join(", "); + const edgeKinds = g.edges.map((e) => e.edgeType).join(", ") || "none"; + return `- **${h.pattern.name}** (similarity ${h.score.toFixed(2)}): ${h.pattern.description}\n Structure: ${g.nodes.length} nodes [${nodeTypes}], edges [${edgeKinds}]`; + }) + .join("\n")}`; + + return `${BASE_PROMPT} +${patternBlock} + +## CURRENT CANVAS STATE (current_graph) +Nodes: +${nodeSummary} + +Edges: +${edgeSummary} + +New additions build on this existing structure; create new nodes with tempId (do not recreate existing nodes).`; +} + +function firstName(props: unknown): string { + if (props && typeof props === "object") { + const p = props as Record; + for (const key of ["TableName", "ServiceName", "ControllerName", "Name", "ClassName", "ViewName", "RepositoryName", "AppName", "ComponentName", "QueueName", "CacheName", "GatewayName", "OrchestratorName", "WorkerName", "HandlerName", "MiddlewareName", "ExceptionName", "ModuleName", "Key"]) { + if (typeof p[key] === "string") return p[key] as string; + } + } + return "?"; +} diff --git a/apps/server/src/ai/providers/llm.factory.ts b/apps/server/src/ai/providers/llm.factory.ts new file mode 100644 index 0000000..846d0bb --- /dev/null +++ b/apps/server/src/ai/providers/llm.factory.ts @@ -0,0 +1,267 @@ +import { ChatOpenAI } from "@langchain/openai"; +import { ChatDeepSeek } from "@langchain/deepseek"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; +import { ChatMistralAI } from "@langchain/mistralai"; +import { ChatGroq } from "@langchain/groq"; +import { ChatOllama } from "@langchain/ollama"; +import { env } from "../../config/env"; + +/** The rest of the codebase treats every chat client as a ChatOpenAI (it only uses the shared + * BaseChatModel surface: invoke / stream / bindTools / .tool_calls). Non-OpenAI providers are + * cast through this alias — every LangChain chat model implements the same interface. */ +export type GenerationChat = ChatOpenAI; + +export type LlmProvider = + | "openai" + | "anthropic" + | "google" + | "deepseek" + | "mistral" + | "groq" + | "openrouter" + | "ollama" + | "bedrock" + | "openai-compatible"; + +export interface ChatOpts { + /** true: tool-calling mode (no response_format; used with bindTools by the streaming agent). + * false (default): JSON-object mode (the legacy monolithic chat() flow). */ + toolCalling?: boolean; + /** true: token streaming (llm.stream() yields real chunks — instruct mode). */ + streaming?: boolean; + /** Logical tier; the factory maps it to the active provider's model. "agent" = architecture + * generation (high capability), "instruct" = chat (fast). Undefined = provider default. */ + tier?: "agent" | "instruct"; + /** Explicit model override (rare — usually use `tier` or the LLM_MODEL env). */ + model?: string; +} + +const COMMON = { temperature: 0.3, timeout: 120_000, maxRetries: 1 } as const; + +/** opts.model > LLM_MODEL (global override) > the provider's default for this tier. */ +function pickModel(opts: ChatOpts, agentDefault: string, instructDefault = agentDefault): string { + if (opts.model) return opts.model; + if (env.LLM_MODEL) return env.LLM_MODEL; + return opts.tier === "instruct" ? instructDefault : agentDefault; +} + +interface ProviderDef { + /** Env var the user must set for this provider (shown by env-check when missing). */ + envHint: string; + /** Whether the provider has the credentials/config it needs. */ + configured: () => boolean; + /** Tool-calling capable — required for the Architect (agent mode). Advisory. */ + supportsTools: boolean; + /** Build a chat client (cast to ChatOpenAI — see GenerationChat). */ + build: (opts: ChatOpts) => ChatOpenAI; +} + +/** Provider registry. Adding a provider = one entry here + (if a new SDK) a dependency. + * Provider-specific quirks stay local to each builder (e.g. DeepSeek's json_object/thinking). */ +const PROVIDERS: Record = { + openai: { + envHint: "OPENAI_API_KEY", + configured: () => !!env.OPENAI_API_KEY, + supportsTools: true, + build: (o) => + new ChatOpenAI({ + ...COMMON, + model: pickModel(o, "gpt-4o"), + apiKey: env.OPENAI_API_KEY, + maxTokens: 16000, + streaming: o.streaming ?? false, + ...(env.OPENAI_BASE_URL ? { configuration: { baseURL: env.OPENAI_BASE_URL } } : {}), + // OpenAI supports JSON-object mode (conflicts with tools, so only when not tool-calling). + ...(!o.toolCalling ? { modelKwargs: { response_format: { type: "json_object" } } } : {}), + }), + }, + + anthropic: { + envHint: "ANTHROPIC_API_KEY", + configured: () => !!env.ANTHROPIC_API_KEY, + supportsTools: true, + build: (o) => + new ChatAnthropic({ + ...COMMON, + model: pickModel(o, "claude-3-5-sonnet-latest"), + apiKey: env.ANTHROPIC_API_KEY, + maxTokens: 8192, + streaming: o.streaming ?? false, + }) as unknown as ChatOpenAI, + }, + + google: { + envHint: "GOOGLE_API_KEY", + configured: () => !!env.GOOGLE_API_KEY, + supportsTools: true, + build: (o) => + new ChatGoogleGenerativeAI({ + model: pickModel(o, "gemini-1.5-pro"), + apiKey: env.GOOGLE_API_KEY, + temperature: 0.3, + maxOutputTokens: 8192, + maxRetries: 1, + streaming: o.streaming ?? false, + }) as unknown as ChatOpenAI, + }, + + deepseek: { + envHint: "DEEPSEEK_API_KEY", + configured: () => !!env.DEEPSEEK_API_KEY, + supportsTools: true, + build: (o) => { + // tool-calling mode drops response_format (json_object + tools conflict). Atomic tool + // args are far below the corruption threshold so non-thinking + tools is deterministic. + const modelKwargs: Record = { thinking: { type: "disabled" } }; + if (!o.toolCalling) modelKwargs.response_format = { type: "json_object" }; + // Preserve the two-tier behavior: agent → v4-pro, instruct → v4-flash, untiered → legacy. + const model = + o.model ?? + env.LLM_MODEL ?? + (o.tier === "agent" + ? env.DEEPSEEK_MODEL_AGENT + : o.tier === "instruct" + ? env.DEEPSEEK_MODEL_INSTRUCT + : env.DEEPSEEK_MODEL); + return new ChatDeepSeek({ + ...COMMON, + model, + apiKey: env.DEEPSEEK_API_KEY!, + maxTokens: 32000, // v4 max output is large; a low cap truncated big graph JSON + streaming: o.streaming ?? false, + configuration: { baseURL: env.DEEPSEEK_BASE_URL }, + modelKwargs, + }) as unknown as ChatOpenAI; + }, + }, + + mistral: { + envHint: "MISTRAL_API_KEY", + configured: () => !!env.MISTRAL_API_KEY, + supportsTools: true, + build: (o) => + new ChatMistralAI({ + model: pickModel(o, "mistral-large-latest"), + apiKey: env.MISTRAL_API_KEY, + temperature: 0.3, + maxTokens: 8192, + maxRetries: 1, + streaming: o.streaming ?? false, + }) as unknown as ChatOpenAI, + }, + + groq: { + envHint: "GROQ_API_KEY", + configured: () => !!env.GROQ_API_KEY, + supportsTools: true, + build: (o) => + new ChatGroq({ + model: pickModel(o, "llama-3.3-70b-versatile"), + apiKey: env.GROQ_API_KEY, + temperature: 0.3, + maxTokens: 8192, + maxRetries: 1, + streaming: o.streaming ?? false, + }) as unknown as ChatOpenAI, + }, + + // OpenRouter — an OpenAI-compatible gateway to 300+ models. Model id is "vendor/model". + openrouter: { + envHint: "OPENROUTER_API_KEY", + configured: () => !!env.OPENROUTER_API_KEY, + supportsTools: true, // model-dependent; pick a tool-calling-capable model for the Architect + build: (o) => + new ChatOpenAI({ + ...COMMON, + model: pickModel(o, "openai/gpt-4o"), + apiKey: env.OPENROUTER_API_KEY, + maxTokens: 16000, + streaming: o.streaming ?? false, + configuration: { baseURL: "https://openrouter.ai/api/v1" }, + }), + }, + + // Ollama — fully local/offline, no API key. Inside Docker, point OLLAMA_BASE_URL at the host. + ollama: { + envHint: "OLLAMA_BASE_URL", + configured: () => !!env.OLLAMA_BASE_URL, // always set (defaulted); reachability is runtime + supportsTools: true, // model-dependent (llama3.1+ etc.) + build: (o) => + new ChatOllama({ + model: pickModel(o, "llama3.1"), + baseUrl: env.OLLAMA_BASE_URL, + temperature: 0.3, + streaming: o.streaming ?? false, + }) as unknown as ChatOpenAI, + }, + + // Bedrock-mantle = OpenAI-compatible Chat Completions endpoint + bearer API key. + bedrock: { + envHint: "BEDROCK_API_KEY + BEDROCK_BASE_URL", + configured: () => !!(env.BEDROCK_API_KEY && env.BEDROCK_BASE_URL), + supportsTools: true, + build: (o) => { + if (!env.BEDROCK_API_KEY || !env.BEDROCK_BASE_URL) { + throw new Error("BEDROCK_API_KEY and BEDROCK_BASE_URL are required (provider=bedrock)."); + } + return new ChatOpenAI({ + ...COMMON, + model: pickModel(o, env.BEDROCK_MODEL), + apiKey: env.BEDROCK_API_KEY, + maxTokens: 16000, + streaming: o.streaming ?? false, + configuration: { baseURL: env.BEDROCK_BASE_URL }, + }); + }, + }, + + // Generic OpenAI-compatible endpoint (xAI, Together, Fireworks, Azure, vLLM, LM Studio, ...). + "openai-compatible": { + envHint: "LLM_API_KEY + LLM_BASE_URL + LLM_MODEL", + configured: () => !!(env.LLM_API_KEY && env.LLM_BASE_URL && env.LLM_MODEL), + supportsTools: true, + build: (o) => { + if (!env.LLM_BASE_URL || !env.LLM_MODEL) { + throw new Error("LLM_BASE_URL and LLM_MODEL are required (provider=openai-compatible)."); + } + return new ChatOpenAI({ + ...COMMON, + model: o.model ?? env.LLM_MODEL, + apiKey: env.LLM_API_KEY ?? "not-needed", + maxTokens: 16000, + streaming: o.streaming ?? false, + configuration: { baseURL: env.LLM_BASE_URL }, + }); + }, + }, +}; + +function buildFor(provider: LlmProvider, opts: ChatOpts): GenerationChat { + const def = PROVIDERS[provider]; + if (!def.configured()) { + throw new Error(`AI provider "${provider}" is not configured — set ${def.envHint}.`); + } + return def.build(opts); +} + +/** generation → architecture generation. toolCalling=true drives the atomic agent loop + * (chatStream); false is the full-graph JSON flow (legacy chat()). */ +export function getGenerationChat(opts: ChatOpts = {}): GenerationChat { + return buildFor(env.LLM_GENERATION_PROVIDER, opts); +} + +/** chat → general dialogue / summary. */ +export function getChatChat(opts: ChatOpts = {}): GenerationChat { + return buildFor(env.LLM_CHAT_PROVIDER, opts); +} + +export function isGenerationConfigured(): boolean { + return PROVIDERS[env.LLM_GENERATION_PROVIDER].configured(); +} + +/** For env-check: is the given provider configured, and which env var does it need. */ +export function providerStatus(provider: LlmProvider): { configured: boolean; envHint: string } { + const def = PROVIDERS[provider]; + return { configured: def.configured(), envHint: def.envHint }; +} diff --git a/apps/server/src/ai/tools/apply-architecture-graph.tool.ts b/apps/server/src/ai/tools/apply-architecture-graph.tool.ts new file mode 100644 index 0000000..240c239 --- /dev/null +++ b/apps/server/src/ai/tools/apply-architecture-graph.tool.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +/** plans/AI Agent & Tool Schema — apply_architecture_graph function schema. + * LLM produces {nodes, edges} matching this schema; GraphService.apply processes it. */ +export const ApplyArchitectureArgsSchema = z.object({ + nodes: z.array( + z.object({ + tempId: z.string().describe("Temporary unique ID for reference in edges (e.g. 'temp_user_controller')."), + type: z.string().describe("Node type (Table, Service, Controller, ...)."), + properties: z.record(z.unknown()).describe("Type-specific fields (e.g. TableName + Columns for Table)."), + }), + ).describe("Architecture components to be created."), + edges: z.array( + z.object({ + sourceTempId: z.string().describe("tempId of the node the connection originates from."), + targetTempId: z.string().describe("tempId of the node the connection goes to."), + edgeType: z.string().describe("Relationship type (CALLS, WRITES, REQUESTS, ...)."), + label: z.string().optional().describe("Short label on the arrow (optional)."), + }), + ).describe("Relationships between nodes."), +}); + +export type ApplyArchitectureArgs = z.infer; + +export const APPLY_ARCHITECTURE_TOOL_NAME = "apply_architecture_graph"; + +export const APPLY_ARCHITECTURE_DESCRIPTION = + "Adds new Nodes and Edges to the system according to the user's request. " + + "The submitted draft is strictly checked by the Solarch Rules Engine. " + + "If you violate the rules, the system returns the errors and fix suggestions — " + + "you must read these, revise the structure, and call the function again."; diff --git a/apps/server/src/ai/tools/create-edge.tool.ts b/apps/server/src/ai/tools/create-edge.tool.ts new file mode 100644 index 0000000..483cd50 --- /dev/null +++ b/apps/server/src/ai/tools/create-edge.tool.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { EDGE_KINDS } from "../../edges/schemas/edge.schema"; + +/** Atomic create_edge tool — creates edge between two existing nodes. + * sourceNodeId and targetNodeId must be real backend IDs from prior create_node + * calls (no tempId). Rules Engine runs inline on each create — on violation + * returns { ok: false, suggestion }; LLM self-corrects (ReAct). */ + +export const CREATE_EDGE_TOOL_NAME = "create_edge"; + +export const CreateEdgeArgsSchema = z.object({ + sourceNodeId: z.string().uuid().describe( + "ID of the node the connection originates from (the return value of a previous create_node call).", + ), + targetNodeId: z.string().uuid().describe( + "ID of the node the connection goes to.", + ), + kind: z.enum(EDGE_KINDS as unknown as [string, ...string[]]).describe( + "Edge type. Valid values: " + EDGE_KINDS.join(", "), + ), + label: z.string().optional().describe( + "Short label to show on the arrow (optional). E.g.: 'validate password', 'create user'.", + ), +}); + +export type CreateEdgeArgs = z.infer; + +export const CREATE_EDGE_DESCRIPTION = + "Creates a relationship (edge) between two existing nodes. The source and target nodes " + + "must have been created previously with create_node — use the real IDs returned by that " + + "function, not tempIds. The Solarch Rules Engine validates that the connection conforms " + + "to the rules; on a violation it returns { ok: false, code, message, suggestion }. " + + "Response: { ok: true, id } on success."; diff --git a/apps/server/src/ai/tools/create-node.tool.ts b/apps/server/src/ai/tools/create-node.tool.ts new file mode 100644 index 0000000..55db0ff --- /dev/null +++ b/apps/server/src/ai/tools/create-node.tool.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; +import { NODE_KINDS } from "../../nodes/schemas"; + +/** Atomic create_node tool — in streaming agent loop LLM creates one node, + * backend returns real node ID as tool result. Next turn LLM uses this ID in create_edge. + * + * Used instead of apply-architecture-graph (monolithic). Atomic args + * (~1-2K char) well below tool-call payload corruption threshold (10K) — + * v4-flash + non-thinking + tools run deterministically. */ + +export const CREATE_NODE_TOOL_NAME = "create_node"; + +export const CreateNodeArgsSchema = z.object({ + type: z.enum(NODE_KINDS as [string, ...string[]]).describe( + "Node type. Valid values: " + NODE_KINDS.join(", "), + ), + properties: z.record(z.unknown()).describe( + "Type-specific fields. Example: for Table { TableName, Columns: [...] }, " + + "for Controller { ControllerName, Endpoints: [...] }. Each type's " + + "schema is strictly validated — if a field is missing/wrong an error is returned.", + ), +}); + +export type CreateNodeArgs = z.infer; + +export const CREATE_NODE_DESCRIPTION = + "Creates a single component (node) of the architecture. It passes schema validation " + + "and returns the real node ID. You must create the relevant nodes before creating edges. " + + "Response: { ok: true, id, type } on success, { ok: false, code, message, suggestion } on error. " + + "On error, apply the suggestion and try again."; diff --git a/apps/server/src/ai/tools/delete-edge.tool.ts b/apps/server/src/ai/tools/delete-edge.tool.ts new file mode 100644 index 0000000..6ffeb6d --- /dev/null +++ b/apps/server/src/ai/tools/delete-edge.tool.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +/** Deletes an edge (connection). To REWIRE a connection: + * delete_edge(oldId) + create_edge(new endpoints) — rules re-run. */ + +export const DELETE_EDGE_TOOL_NAME = "delete_edge"; + +export const DeleteEdgeArgsSchema = z.object({ + edgeId: z.string().uuid().describe("ID of the existing edge to delete (from the current-graph edge list)."), +}); + +export type DeleteEdgeArgs = z.infer; + +export const DELETE_EDGE_DESCRIPTION = + "Deletes a relationship (edge) between two nodes. To REWIRE a connection, delete the old edge then call " + + "create_edge with the new endpoints. Response: { ok: true, id } on success, { ok: false, code, message } if not found."; diff --git a/apps/server/src/ai/tools/delete-node.tool.ts b/apps/server/src/ai/tools/delete-node.tool.ts new file mode 100644 index 0000000..2e027d8 --- /dev/null +++ b/apps/server/src/ai/tools/delete-node.tool.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +/** Permanently deletes a node (and its connected edges). For refactors: remove a + * component, clean up a wrongly created node. */ + +export const DELETE_NODE_TOOL_NAME = "delete_node"; + +export const DeleteNodeArgsSchema = z.object({ + nodeId: z.string().uuid().describe("ID of the existing node to delete. Its connected edges are removed too."), +}); + +export type DeleteNodeArgs = z.infer; + +export const DELETE_NODE_DESCRIPTION = + "Permanently deletes a node and its connected edges. Use for refactors such as removing a component. " + + "Response: { ok: true, id } on success, { ok: false, code, message } if the node is not found."; diff --git a/apps/server/src/ai/tools/get-node.tool.ts b/apps/server/src/ai/tools/get-node.tool.ts new file mode 100644 index 0000000..43249f9 --- /dev/null +++ b/apps/server/src/ai/tools/get-node.tool.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +/** Read-only — reads full properties of an existing node. Agent calls this FIRST + * before editing an ARRAY field (Columns/Endpoints/Methods/Fields) to see the + * full array, then sends the complete new array to update_node. */ + +export const GET_NODE_TOOL_NAME = "get_node"; + +export const GetNodeArgsSchema = z.object({ + nodeId: z.string().uuid().describe( + "ID of an existing node (from the current-graph list) to read its full properties before editing.", + ), +}); + +export type GetNodeArgs = z.infer; + +export const GET_NODE_DESCRIPTION = + "Reads the full current properties of an existing node. Call this BEFORE update_node when you need to edit " + + "an array field (e.g. add a column to a Table or an endpoint to a Controller) so you can resend the complete " + + "array. Response: { ok: true, id, type, version, properties }."; diff --git a/apps/server/src/ai/tools/update-node.tool.ts b/apps/server/src/ai/tools/update-node.tool.ts new file mode 100644 index 0000000..47d46a4 --- /dev/null +++ b/apps/server/src/ai/tools/update-node.tool.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +/** Modifies an existing node — rename, description/flag, or replace an array. + * Given properties shallow-merge over current properties; result passes strict + * schema validation. Node type CANNOT be changed. */ + +export const UPDATE_NODE_TOOL_NAME = "update_node"; + +export const UpdateNodeArgsSchema = z.object({ + nodeId: z.string().uuid().describe("ID of the existing node to modify (from the current-graph list)."), + properties: z.record(z.unknown()).describe( + "Top-level property fields to change; merged over the node's current properties (untouched fields are kept). " + + "To RENAME, send just the name field (e.g. { ServiceName: 'AccountService' }). " + + "To edit an ARRAY field (Columns/Endpoints/Methods/Fields), first call get_node, then send the COMPLETE new " + + "array — arrays REPLACE, they do not append. The merged result is strictly schema-validated.", + ), +}); + +export type UpdateNodeArgs = z.infer; + +export const UPDATE_NODE_DESCRIPTION = + "Modifies an existing node (rename, change a description/flag, or replace an array field). The node type cannot " + + "be changed. The provided properties are merged over the current ones and the result is strictly validated. " + + "Response: { ok: true, id, version } on success, { ok: false, code, message, suggestion } on error " + + "(e.g. ERR_VERSION_CONFLICT, ERR_NAME_DUPLICATE, validation errors)."; diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts new file mode 100644 index 0000000..1a60073 --- /dev/null +++ b/apps/server/src/app.module.ts @@ -0,0 +1,45 @@ +import { Module } from "@nestjs/common"; +import { APP_GUARD } from "@nestjs/core"; +import { ThrottlerModule } from "@nestjs/throttler"; +import { UserThrottlerGuard } from "./common/guards/user-throttler.guard"; +import { Neo4jModule } from "./neo4j/neo4j.module"; +import { AuthModule } from "./auth/auth.module"; +import { ProjectsModule } from "./projects/projects.module"; +import { NodesModule } from "./nodes/nodes.module"; +import { NodeTypesModule } from "./node-types/node-types.module"; +import { EdgesModule } from "./edges/edges.module"; +import { EdgeTypesModule } from "./edge-types/edge-types.module"; +import { RulesModule } from "./rules/rules.module"; +import { GraphModule } from "./graph/graph.module"; +import { CodegenModule } from "./codegen/codegen.module"; +import { AiModule } from "./ai/ai.module"; +import { EmbeddingsModule } from "./embeddings/embeddings.module"; +import { PatternsModule } from "./patterns/patterns.module"; +import { TabsModule } from "./tabs/tabs.module"; +import { ValueSetsModule } from "./value-sets/value-sets.module"; +import { HealthController } from "./health/health.controller"; + +@Module({ + imports: [ +// Global rate-limit: 60 requests/min/user (expensive ends are tighter with @Throttle). + ThrottlerModule.forRoot([{ ttl: 60_000, limit: 60 }]), + Neo4jModule, + AuthModule, + ProjectsModule, + NodesModule, + NodeTypesModule, + EdgesModule, + EdgeTypesModule, + RulesModule, + GraphModule, + CodegenModule, + AiModule, + EmbeddingsModule, + PatternsModule, + TabsModule, + ValueSetsModule, + ], + controllers: [HealthController], + providers: [{ provide: APP_GUARD, useClass: UserThrottlerGuard }], +}) +export class AppModule {} diff --git a/apps/server/src/auth/access.ts b/apps/server/src/auth/access.ts new file mode 100644 index 0000000..c0f4fd7 --- /dev/null +++ b/apps/server/src/auth/access.ts @@ -0,0 +1,22 @@ +import type { AuthContext } from "./auth.types"; + +/** Project access rule: + * - When org context is active (auth.orgId set): project must belong to that org. + * - In personal context: project must belong to caller and not be org-owned. */ +export function hasProjectAccess( + project: { ownerId: string; orgId: string | null }, + auth: AuthContext, +): boolean { + if (auth.orgId) return project.orgId === auth.orgId; + return project.ownerId === auth.userId && project.orgId == null; +} + +/** Ownership stamped on new projects. */ +export function ownershipFor(auth: AuthContext): { ownerId: string; orgId: string | null } { + return { ownerId: auth.userId, orgId: auth.orgId }; +} + +/** Scope filter for list(). */ +export function projectScope(auth: AuthContext): { userId: string; orgId: string | null } { + return { userId: auth.userId, orgId: auth.orgId }; +} diff --git a/apps/server/src/auth/api-keys/api-keys.controller.ts b/apps/server/src/auth/api-keys/api-keys.controller.ts new file mode 100644 index 0000000..51f3aa6 --- /dev/null +++ b/apps/server/src/auth/api-keys/api-keys.controller.ts @@ -0,0 +1,63 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + NotFoundException, + Param, + Post, +} from "@nestjs/common"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { createZodDto } from "nestjs-zod"; +import { z } from "zod"; +import { ok } from "../../common/envelope"; +import { CurrentAuth } from "../current-auth.decorator"; +import type { AuthContext } from "../auth.types"; +import { ApiKeysService } from "./api-keys.service"; + +const CreateApiKeySchema = z.object({ + name: z.string().min(1).max(64).describe("Purpose of the key (e.g. 'CI pipeline', 'laptop')"), +}); +class CreateApiKeyDto extends createZodDto(CreateApiKeySchema) {} + +/** API key management for terminal clients such as CLI/MCP. */ +@ApiTags("API Keys") +@Controller("api-keys") +export class ApiKeysController { + constructor(private readonly service: ApiKeysService) {} + + @Post() + @HttpCode(201) + @ApiOperation({ + summary: "Create new API key", + description: + "Personal access key for the CLI (`solarch login`). The plaintext key is returned only in this response — " + + "the server stores only its SHA-256 hash, so it can never be shown again.", + }) + @ApiResponse({ status: 201, description: "`data: { key, id, name, prefix, createdAt }`." }) + async create(@Body() body: CreateApiKeyDto, @CurrentAuth() auth: AuthContext) { + const { key, record } = await this.service.create(auth.userId, body.name); + return ok({ key, id: record.id, name: record.name, prefix: record.prefix, createdAt: record.createdAt }); + } + + @Get() + @ApiOperation({ summary: "List my keys", description: "Plaintext key is not returned; prefix + metadata." }) + @ApiResponse({ status: 200, description: "`data: { keys: [...] }`." }) + async list(@CurrentAuth() auth: AuthContext) { + const keys = await this.service.list(auth.userId); + return ok({ keys }); + } + + @Delete(":keyId") + @ApiOperation({ summary: "Revoke key", description: "The key is invalidated immediately." }) + @ApiResponse({ status: 200, description: "`data: { deleted: true }`." }) + @ApiResponse({ status: 404, description: "`ERR_API_KEY_NOT_FOUND`." }) + async remove(@Param("keyId") keyId: string, @CurrentAuth() auth: AuthContext) { + const deleted = await this.service.remove(auth.userId, keyId); + if (!deleted) { + throw new NotFoundException({ code: "ERR_API_KEY_NOT_FOUND", message: "API key not found." }); + } + return ok({ deleted: true }); + } +} diff --git a/apps/server/src/auth/api-keys/api-keys.repository.ts b/apps/server/src/auth/api-keys/api-keys.repository.ts new file mode 100644 index 0000000..cb76429 --- /dev/null +++ b/apps/server/src/auth/api-keys/api-keys.repository.ts @@ -0,0 +1,77 @@ +import { Injectable } from "@nestjs/common"; +import { Neo4jService } from "../../neo4j/neo4j.service"; + +/** API key record — the key itself is NEVER stored, only SHA-256 hash. + * prefix (slk_ + first 6 chars) identifies which key in listings. */ +export interface StoredApiKey { + id: string; + userId: string; + name: string; + prefix: string; + createdAt: string; + lastUsedAt: string | null; +} + +@Injectable() +export class ApiKeysRepository { + constructor(private readonly neo4j: Neo4jService) {} + + async create(key: StoredApiKey & { hash: string }): Promise { + await this.neo4j.run( + `CREATE (k:ApiKey { + id: $id, userId: $userId, name: $name, prefix: $prefix, + hash: $hash, createdAt: $createdAt, lastUsedAt: null + })`, + { ...key }, + ); + } + + async listByUser(userId: string): Promise { + const r = await this.neo4j.run( + `MATCH (k:ApiKey {userId: $userId}) RETURN k ORDER BY k.createdAt DESC`, + { userId }, + ); + return r.records.map((rec) => { + const p = rec.get("k").properties; + return { + id: p.id, + userId: p.userId, + name: p.name, + prefix: p.prefix, + createdAt: p.createdAt, + lastUsedAt: p.lastUsedAt ?? null, + }; + }); + } + + /** Lookup by hash — high-entropy key makes exact-match sufficient. */ + async findByHash(hash: string): Promise { + const r = await this.neo4j.run(`MATCH (k:ApiKey {hash: $hash}) RETURN k`, { hash }); + if (!r.records.length) return null; + const p = r.records[0].get("k").properties; + return { + id: p.id, + userId: p.userId, + name: p.name, + prefix: p.prefix, + createdAt: p.createdAt, + lastUsedAt: p.lastUsedAt ?? null, + }; + } + + /** Ownership-conditional delete — cannot delete another user's key (BOLA). */ + async deleteOwned(id: string, userId: string): Promise { + const r = await this.neo4j.run( + `MATCH (k:ApiKey {id: $id, userId: $userId}) DELETE k RETURN count(*) AS n`, + { id, userId }, + ); + return (r.records[0]?.get("n")?.toNumber?.() ?? r.records[0]?.get("n") ?? 0) > 0; + } + + async touchLastUsed(id: string): Promise { + await this.neo4j.run( + `MATCH (k:ApiKey {id: $id}) SET k.lastUsedAt = $now`, + { id, now: new Date().toISOString() }, + ); + } +} diff --git a/apps/server/src/auth/api-keys/api-keys.service.ts b/apps/server/src/auth/api-keys/api-keys.service.ts new file mode 100644 index 0000000..9936d07 --- /dev/null +++ b/apps/server/src/auth/api-keys/api-keys.service.ts @@ -0,0 +1,65 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import { ApiKeysRepository, type StoredApiKey } from "./api-keys.repository"; + +export const API_KEY_PREFIX = "slk_"; +const MAX_KEYS_PER_USER = 10; +/** Update lastUsedAt at most once every 5 minutes, not on every request (write noise). */ +const TOUCH_INTERVAL_MS = 5 * 60_000; + +function hashKey(rawKey: string): string { + return createHash("sha256").update(rawKey).digest("hex"); +} + +@Injectable() +export class ApiKeysService { + /** keyId → last touch time (in-process; multi-instance worst case: a few extra writes). */ + private readonly lastTouch = new Map(); + + constructor(private readonly repo: ApiKeysRepository) {} + + /** Create new key — plaintext key returned ONLY in this response, never shown again. */ + async create(userId: string, name: string): Promise<{ key: string; record: StoredApiKey }> { + const existing = await this.repo.listByUser(userId); + if (existing.length >= MAX_KEYS_PER_USER) { + throw new BadRequestException({ + code: "ERR_API_KEY_LIMIT", + message: `You can create at most ${MAX_KEYS_PER_USER} API keys. Delete unused ones.`, + }); + } + + const rawKey = `${API_KEY_PREFIX}${randomBytes(24).toString("hex")}`; // slk_ + 48 hex + const record: StoredApiKey = { + id: randomUUID(), + userId, + name: name.trim() || "unnamed", + prefix: rawKey.slice(0, API_KEY_PREFIX.length + 6), + createdAt: new Date().toISOString(), + lastUsedAt: null, + }; + await this.repo.create({ ...record, hash: hashKey(rawKey) }); + return { key: rawKey, record }; + } + + list(userId: string): Promise { + return this.repo.listByUser(userId); + } + + async remove(userId: string, id: string): Promise { + return this.repo.deleteOwned(id, userId); + } + + /** Bearer slk_... validation — returns key owner's identity when valid. */ + async verify(rawKey: string): Promise<{ userId: string } | null> { + if (!rawKey.startsWith(API_KEY_PREFIX)) return null; + const found = await this.repo.findByHash(hashKey(rawKey)); + if (!found) return null; + + const last = this.lastTouch.get(found.id) ?? 0; + if (Date.now() - last > TOUCH_INTERVAL_MS) { + this.lastTouch.set(found.id, Date.now()); + void this.repo.touchLastUsed(found.id).catch(() => undefined); + } + return { userId: found.userId }; + } +} diff --git a/apps/server/src/auth/auth.module.ts b/apps/server/src/auth/auth.module.ts new file mode 100644 index 0000000..ad520f8 --- /dev/null +++ b/apps/server/src/auth/auth.module.ts @@ -0,0 +1,36 @@ +import { Global, Module } from "@nestjs/common"; +import { APP_GUARD } from "@nestjs/core"; +import { ProjectsRepository } from "../projects/projects.repository"; +import { LocalAuthGuard } from "./local-auth.guard"; +import { ProjectAccessGuard } from "./project-access.guard"; +import { ApiKeysController } from "./api-keys/api-keys.controller"; +import { ApiKeysService } from "./api-keys/api-keys.service"; +import { ApiKeysRepository } from "./api-keys/api-keys.repository"; + +/** Global auth layer: LocalAuthGuard applies to all routes as APP_GUARD; + * ProjectAccessGuard is used via @UseGuards on sub-resource controllers and + * exported globally. + * + * We provide ProjectsRepository directly without importing ProjectsModule: + * importing ProjectsModule → TabsModule chain (because TabsController consumes + * the guard) caused circular init. ProjectsRepository's only dependency is + * @Global Neo4jService, so providing our own instance is safe. */ +@Global() +@Module({ + controllers: [ApiKeysController], + providers: [ + // useExisting → reuses LocalAuthGuard provider for APP_GUARD so tests can + // overrideProvider(LocalAuthGuard) and affect the global guard too. + LocalAuthGuard, + { provide: APP_GUARD, useExisting: LocalAuthGuard }, + ProjectsRepository, + ProjectAccessGuard, + ApiKeysService, + ApiKeysRepository, + ], + // ProjectsRepository is also exported: @UseGuards(ProjectAccessGuard) instantiates + // the guard ad hoc in each host module, so its dependency must be globally visible + // (exporting ProjectAccessGuard alone is not enough). + exports: [ProjectAccessGuard, ProjectsRepository, ApiKeysService], +}) +export class AuthModule {} diff --git a/apps/server/src/auth/auth.types.ts b/apps/server/src/auth/auth.types.ts new file mode 100644 index 0000000..df62c76 --- /dev/null +++ b/apps/server/src/auth/auth.types.ts @@ -0,0 +1,9 @@ +/** Identity placed on the request context (req.auth) — from LocalAuthGuard or API key. */ +export interface AuthContext { + /** Local owner id or API-key owner. */ + userId: string; + /** Reserved for future workspace scoping. Always null in OSS edition. */ + orgId: string | null; + /** Reserved for future workspace roles. Always null in OSS edition. */ + orgRole: string | null; +} diff --git a/apps/server/src/auth/current-auth.decorator.ts b/apps/server/src/auth/current-auth.decorator.ts new file mode 100644 index 0000000..5a5559a --- /dev/null +++ b/apps/server/src/auth/current-auth.decorator.ts @@ -0,0 +1,10 @@ +import { createParamDecorator, type ExecutionContext } from "@nestjs/common"; +import type { AuthContext } from "./auth.types"; + +/** Injects req.auth into a controller method (populated by LocalAuthGuard). */ +export const CurrentAuth = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): AuthContext => { + const req = ctx.switchToHttp().getRequest<{ auth?: AuthContext }>(); + return req.auth as AuthContext; + }, +); diff --git a/apps/server/src/auth/local-auth.guard.spec.ts b/apps/server/src/auth/local-auth.guard.spec.ts new file mode 100644 index 0000000..fba3c04 --- /dev/null +++ b/apps/server/src/auth/local-auth.guard.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { UnauthorizedException } from "@nestjs/common"; + +vi.mock("../config/env", () => ({ + env: { LOCAL_USER_ID: "local_owner" }, +})); + +import { LocalAuthGuard } from "./local-auth.guard"; +import type { AuthContext } from "./auth.types"; + +const verifyApiKey = vi.fn(); +const fakeApiKeys = { verify: (...a: unknown[]) => verifyApiKey(...a) }; + +function ctxFor(req: Record, isPublic = false) { + const reflector = { getAllAndOverride: vi.fn(() => isPublic) }; + const context = { + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ getRequest: () => req }), + }; + return { + guard: new LocalAuthGuard(reflector as never, fakeApiKeys as never), + context: context as never, + req, + }; +} + +describe("LocalAuthGuard", () => { + beforeEach(() => { + verifyApiKey.mockReset(); + }); + + it("@Public routes skip auth", async () => { + const { guard, context } = ctxFor({}, true); + await expect(guard.canActivate(context)).resolves.toBe(true); + }); + + it("no API key → injects LOCAL_USER_ID", async () => { + const req: { auth?: AuthContext; headers: Record } = { headers: {} }; + const { guard, context } = ctxFor(req); + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(req.auth).toEqual({ userId: "local_owner", orgId: null, orgRole: null }); + }); + + it("valid API key (Bearer slk_) → key owner's identity", async () => { + verifyApiKey.mockResolvedValue({ userId: "user_cli" }); + const req: { auth?: AuthContext; headers: Record } = { + headers: { authorization: "Bearer slk_abc123" }, + }; + const { guard, context } = ctxFor(req); + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(verifyApiKey).toHaveBeenCalledWith("slk_abc123"); + expect(req.auth).toEqual({ userId: "user_cli", orgId: null, orgRole: null }); + }); + + it("valid API key (X-Solarch-Api-Key) → key owner's identity", async () => { + verifyApiKey.mockResolvedValue({ userId: "user_cli" }); + const req: { auth?: AuthContext; headers: Record } = { + headers: { "x-solarch-api-key": "slk_abc123" }, + }; + const { guard, context } = ctxFor(req); + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(verifyApiKey).toHaveBeenCalledWith("slk_abc123"); + expect(req.auth).toEqual({ userId: "user_cli", orgId: null, orgRole: null }); + }); + + it("invalid API key → 401 ERR_API_KEY_INVALID", async () => { + verifyApiKey.mockResolvedValue(null); + const { guard, context } = ctxFor({ headers: { authorization: "Bearer slk_revoked" } }); + await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); + }); +}); diff --git a/apps/server/src/auth/local-auth.guard.ts b/apps/server/src/auth/local-auth.guard.ts new file mode 100644 index 0000000..b39bafc --- /dev/null +++ b/apps/server/src/auth/local-auth.guard.ts @@ -0,0 +1,70 @@ +import { + CanActivate, + type ExecutionContext, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { env } from "../config/env"; +import { IS_PUBLIC_KEY } from "./public.decorator"; +import { API_KEY_PREFIX, ApiKeysService } from "./api-keys/api-keys.service"; +import type { AuthContext } from "./auth.types"; + +function headerOne(headers: Record, name: string): string | undefined { + const raw = headers[name.toLowerCase()] ?? headers[name]; + return Array.isArray(raw) ? raw[0] : raw; +} + +/** Global guard (APP_GUARD). Every request gets the local owner identity unless + * the caller presents a valid API key (Authorization: Bearer slk_* or X-Solarch-Api-Key). */ +@Injectable() +export class LocalAuthGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly apiKeys: ApiKeysService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const req = context.switchToHttp().getRequest(); + const headers = (req as { headers?: Record }).headers ?? {}; + + const rawAuthz = headerOne(headers, "authorization"); + const apiKeyHeader = headerOne(headers, "x-solarch-api-key"); + const rawKey = rawAuthz?.startsWith(`Bearer ${API_KEY_PREFIX}`) + ? rawAuthz.slice("Bearer ".length) + : apiKeyHeader?.startsWith(API_KEY_PREFIX) + ? apiKeyHeader + : undefined; + + if (rawKey) { + const verified = await this.apiKeys.verify(rawKey); + if (verified) { + const ctx: AuthContext = { + userId: verified.userId, + orgId: null, + orgRole: null, + }; + (req as { auth?: AuthContext }).auth = ctx; + return true; + } + throw new UnauthorizedException({ + code: "ERR_API_KEY_INVALID", + message: "API key is invalid or revoked.", + }); + } + + const ctx: AuthContext = { + userId: env.LOCAL_USER_ID, + orgId: null, + orgRole: null, + }; + (req as { auth?: AuthContext }).auth = ctx; + return true; + } +} diff --git a/apps/server/src/auth/project-access.guard.ts b/apps/server/src/auth/project-access.guard.ts new file mode 100644 index 0000000..72a5ada --- /dev/null +++ b/apps/server/src/auth/project-access.guard.ts @@ -0,0 +1,40 @@ +import { + CanActivate, + type ExecutionContext, + ForbiddenException, + Injectable, +} from "@nestjs/common"; +import { ProjectsRepository } from "../projects/projects.repository"; +import { hasProjectAccess } from "./access"; +import type { AuthContext } from "./auth.types"; + +/** Multi-tenancy (BOLA) enforcement on /projects/:projectId/* sub-resources. + * Passes when projectId param is absent (non-project-scoped route) — global LocalAuthGuard + * already guarantees authentication. Returns 403 if the project is missing OR not owned by + * the caller (same response for both to prevent existence leakage). */ +@Injectable() +export class ProjectAccessGuard implements CanActivate { + constructor(private readonly projects: ProjectsRepository) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest<{ + params?: Record; + auth?: AuthContext; + }>(); + const projectId = req.params?.projectId; + if (!projectId) return true; + + const auth = req.auth; + if (!auth) { + throw new ForbiddenException({ code: "ERR_FORBIDDEN", message: "Access denied." }); + } + const project = await this.projects.getById(projectId); + if (!project || !hasProjectAccess(project, auth)) { + throw new ForbiddenException({ + code: "ERR_PROJECT_FORBIDDEN", + message: "You do not have access to this project.", + }); + } + return true; + } +} diff --git a/apps/server/src/auth/public.decorator.ts b/apps/server/src/auth/public.decorator.ts new file mode 100644 index 0000000..db9a322 --- /dev/null +++ b/apps/server/src/auth/public.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from "@nestjs/common"; + +export const IS_PUBLIC_KEY = "isPublic"; + +/** Exempts a route/controller from the global LocalAuthGuard (e.g. /health). */ +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/server/src/codegen/__fixtures__/load.ts b/apps/server/src/codegen/__fixtures__/load.ts new file mode 100644 index 0000000..cbfd5a3 --- /dev/null +++ b/apps/server/src/codegen/__fixtures__/load.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { buildCodeGraph } from "../ir"; +import { CodegenService } from "../codegen.service"; +import type { GeneratedFile, GeneratedProject } from "../types"; +import type { StoredNode } from "../../nodes/nodes.repository"; +import type { StoredEdge } from "../../edges/edges.repository"; + +/* ──────────────────────────────────────────────────────────────────────── + * __fixtures__/load.ts — shared fixture loader for VERIFICATION GATES. + * + * realistic-graph.json: realistic graph (61 nodes / 82 edges — restaurant + * app), taken from canvas output format and normalized to StoredNode/StoredEdge. + * Both fast seam test (codegen-assembly.spec) and whole-project tsc gate + * (codegen-tsc.gate) use this as SINGLE SOURCE. + * ──────────────────────────────────────────────────────────────────────── */ + +export function loadRealisticGraph(): { nodes: StoredNode[]; edges: StoredEdge[] } { + return JSON.parse(readFileSync(join(__dirname, "realistic-graph.json"), "utf8")); +} + +/** Assemble realistic graph without DB and return FULL project (files + warnings + summary). */ +export function assembleRealisticProject(): GeneratedProject { + const { nodes, edges } = loadRealisticGraph(); + const graph = buildCodeGraph(nodes, edges); + return CodegenService.prototype.assemble.call({} as CodegenService, graph, "nestjs"); +} + +/** Assemble realistic graph without DB and return generated files. */ +export function assembleRealisticFixture(): GeneratedFile[] { + return assembleRealisticProject().files; +} diff --git a/apps/server/src/codegen/__fixtures__/realistic-graph.json b/apps/server/src/codegen/__fixtures__/realistic-graph.json new file mode 100644 index 0000000..73dd5c8 --- /dev/null +++ b/apps/server/src/codegen/__fixtures__/realistic-graph.json @@ -0,0 +1,5188 @@ +{ + "nodes": [ + { + "id": "8d92444d-7a4e-42d1-901d-d260b916c172", + "type": "Table", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1628, + "positionY": 5888, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:46:45.810Z", + "updatedAt": "2026-06-07T15:46:45.810Z", + "version": 1, + "properties": { + "TableName": "Reservations", + "Description": "Table reservations at restaurants", + "Columns": [ + { + "Name": "Id", + "DataType": "UUID", + "IsPrimaryKey": true, + "IsNotNull": true, + "IsUnique": true, + "AutoIncrement": false + }, + { + "Name": "UserId", + "DataType": "UUID", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "RestaurantId", + "DataType": "UUID", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "ReservationDate", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "PartySize", + "DataType": "INT", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "Status", + "DataType": "ENUM", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false, + "EnumRef": "ReservationStatus" + }, + { + "Name": "Notes", + "DataType": "TEXT", + "IsPrimaryKey": false, + "IsNotNull": false, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "CreatedAt", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "UpdatedAt", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + } + ], + "ForeignKeys": [ + { + "Columns": [ + "UserId" + ], + "ReferencesTable": "Users", + "ReferencesColumns": [ + "Id" + ], + "OnDelete": "RESTRICT", + "OnUpdate": "NO_ACTION" + }, + { + "Columns": [ + "RestaurantId" + ], + "ReferencesTable": "Restaurants", + "ReferencesColumns": [ + "Id" + ], + "OnDelete": "RESTRICT", + "OnUpdate": "NO_ACTION" + } + ], + "UniqueConstraints": [], + "CheckConstraints": [], + "Indexes": [] + } + }, + { + "id": "138616a2-04e0-4666-bae5-2a0d0abcade1", + "type": "Service", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1228, + "positionY": 386, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:48:00.669Z", + "updatedAt": "2026-06-07T15:48:00.669Z", + "version": 1, + "properties": { + "ServiceName": "UserService", + "Description": "Manages user profile operations", + "IsTransactionScoped": false, + "Methods": [ + { + "MethodName": "GetById", + "Visibility": "public", + "Parameters": [ + { + "Name": "userId", + "Type": "UUID", + "Optional": false + } + ], + "ReturnType": "UserResponse", + "ReturnDtoRef": "UserResponse", + "IsAsync": true, + "Throws": [ + "NotFoundException" + ], + "Description": "Retrieves user by ID" + }, + { + "MethodName": "GetByEmail", + "Visibility": "public", + "Parameters": [ + { + "Name": "email", + "Type": "VARCHAR", + "Optional": false + } + ], + "ReturnType": "UserResponse", + "ReturnDtoRef": "UserResponse", + "IsAsync": true, + "Throws": [ + "NotFoundException" + ], + "Description": "Retrieves user by email" + } + ], + "Dependencies": [ + { + "Kind": "Repository", + "Ref": "UserRepository" + } + ] + } + }, + { + "id": "3ec99a80-f6e0-433e-b083-2a360238066e", + "type": "Service", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 828, + "positionY": 48, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:48:00.684Z", + "updatedAt": "2026-06-07T15:48:00.684Z", + "version": 1, + "properties": { + "ServiceName": "RestaurantService", + "Description": "Manages restaurant CRUD and listing operations", + "IsTransactionScoped": false, + "Methods": [ + { + "MethodName": "GetAll", + "Visibility": "public", + "Parameters": [], + "ReturnType": "array", + "ReturnDtoRef": "RestaurantResponse", + "IsAsync": true, + "Throws": [], + "Description": "Lists all active restaurants" + }, + { + "MethodName": "GetById", + "Visibility": "public", + "Parameters": [ + { + "Name": "restaurantId", + "Type": "UUID", + "Optional": false + } + ], + "ReturnType": "RestaurantResponse", + "ReturnDtoRef": "RestaurantResponse", + "IsAsync": true, + "Throws": [ + "NotFoundException" + ], + "Description": "Retrieves restaurant details" + }, + { + "MethodName": "Create", + "Visibility": "public", + "Parameters": [ + { + "Name": "request", + "Type": "RestaurantResponse", + "Optional": false, + "DtoRef": "RestaurantResponse" + } + ], + "ReturnType": "RestaurantResponse", + "ReturnDtoRef": "RestaurantResponse", + "IsAsync": true, + "Throws": [ + "ValidationException" + ], + "Description": "Creates a new restaurant" + }, + { + "MethodName": "Search", + "Visibility": "public", + "Parameters": [ + { + "Name": "cuisineType", + "Type": "VARCHAR", + "Optional": true + }, + { + "Name": "keyword", + "Type": "VARCHAR", + "Optional": true + } + ], + "ReturnType": "array", + "ReturnDtoRef": "RestaurantResponse", + "IsAsync": true, + "Throws": [], + "Description": "Searches restaurants by cuisine or keyword" + } + ], + "Dependencies": [ + { + "Kind": "Repository", + "Ref": "RestaurantRepository" + }, + { + "Kind": "Cache", + "Ref": "RedisCache" + } + ] + } + }, + { + "id": "bb80edd5-3205-4b4e-94cb-d6c4b3216eea", + "type": "Service", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 828, + "positionY": 5609, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:48:00.758Z", + "updatedAt": "2026-06-07T15:48:00.758Z", + "version": 1, + "properties": { + "ServiceName": "MenuService", + "Description": "Manages menu items for restaurants", + "IsTransactionScoped": false, + "Methods": [ + { + "MethodName": "GetByRestaurantId", + "Visibility": "public", + "Parameters": [ + { + "Name": "restaurantId", + "Type": "UUID", + "Optional": false + } + ], + "ReturnType": "array", + "ReturnDtoRef": "MenuItemResponse", + "IsAsync": true, + "Throws": [], + "Description": "Lists menu items for a restaurant" + }, + { + "MethodName": "GetById", + "Visibility": "public", + "Parameters": [ + { + "Name": "menuItemId", + "Type": "UUID", + "Optional": false + } + ], + "ReturnType": "MenuItemResponse", + "ReturnDtoRef": "MenuItemResponse", + "IsAsync": true, + "Throws": [ + "NotFoundException" + ], + "Description": "Retrieves a single menu item" + }, + { + "MethodName": "Create", + "Visibility": "public", + "Parameters": [ + { + "Name": "request", + "Type": "MenuItemResponse", + "Optional": false, + "DtoRef": "MenuItemResponse" + } + ], + "ReturnType": "MenuItemResponse", + "ReturnDtoRef": "MenuItemResponse", + "IsAsync": true, + "Throws": [ + "ValidationException" + ], + "Description": "Adds a new menu item" + } + ], + "Dependencies": [ + { + "Kind": "Repository", + "Ref": "MenuItemRepository" + }, + { + "Kind": "Cache", + "Ref": "RedisCache" + } + ] + } + }, + { + "id": "7ea78369-5543-4873-9186-f26f37442a4c", + "type": "Table", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1628, + "positionY": 1849, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:46:45.902Z", + "updatedAt": "2026-06-07T15:46:45.902Z", + "version": 1, + "properties": { + "TableName": "Reviews", + "Description": "Customer reviews for restaurants", + "Columns": [ + { + "Name": "Id", + "DataType": "UUID", + "IsPrimaryKey": true, + "IsNotNull": true, + "IsUnique": true, + "AutoIncrement": false + }, + { + "Name": "UserId", + "DataType": "UUID", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "RestaurantId", + "DataType": "UUID", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "Rating", + "DataType": "INT", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "Comment", + "DataType": "TEXT", + "IsPrimaryKey": false, + "IsNotNull": false, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "CreatedAt", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "UpdatedAt", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + } + ], + "ForeignKeys": [ + { + "Columns": [ + "UserId" + ], + "ReferencesTable": "Users", + "ReferencesColumns": [ + "Id" + ], + "OnDelete": "RESTRICT", + "OnUpdate": "NO_ACTION" + }, + { + "Columns": [ + "RestaurantId" + ], + "ReferencesTable": "Restaurants", + "ReferencesColumns": [ + "Id" + ], + "OnDelete": "CASCADE", + "OnUpdate": "NO_ACTION" + } + ], + "UniqueConstraints": [ + { + "Columns": [ + "UserId", + "RestaurantId" + ] + } + ], + "CheckConstraints": [ + { + "Expression": "Rating >= 1 AND Rating <= 5" + } + ], + "Indexes": [] + } + }, + { + "id": "90fdb61a-921b-4b1a-83ef-a54976315f94", + "type": "Table", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1628, + "positionY": 5622, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:46:45.716Z", + "updatedAt": "2026-06-07T15:46:45.716Z", + "version": 1, + "properties": { + "TableName": "OrderItems", + "Description": "Individual items within an order", + "Columns": [ + { + "Name": "Id", + "DataType": "UUID", + "IsPrimaryKey": true, + "IsNotNull": true, + "IsUnique": true, + "AutoIncrement": false + }, + { + "Name": "OrderId", + "DataType": "UUID", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "MenuItemId", + "DataType": "UUID", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "Quantity", + "DataType": "INT", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "UnitPrice", + "DataType": "DECIMAL", + "Precision": 10, + "Scale": 2, + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + } + ], + "ForeignKeys": [ + { + "Columns": [ + "OrderId" + ], + "ReferencesTable": "Orders", + "ReferencesColumns": [ + "Id" + ], + "OnDelete": "CASCADE", + "OnUpdate": "NO_ACTION" + }, + { + "Columns": [ + "MenuItemId" + ], + "ReferencesTable": "MenuItems", + "ReferencesColumns": [ + "Id" + ], + "OnDelete": "RESTRICT", + "OnUpdate": "NO_ACTION" + } + ], + "UniqueConstraints": [], + "CheckConstraints": [], + "Indexes": [] + } + }, + { + "id": "c14b1bde-d739-46ce-8c17-809bdcd38c27", + "type": "Enum", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 2038, + "positionY": 5356, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:46:45.965Z", + "updatedAt": "2026-06-07T15:46:45.965Z", + "version": 1, + "properties": { + "Name": "OrderStatus", + "Description": "Tracks the lifecycle of an order", + "BackingType": "string", + "Values": [ + { + "Key": "PENDING", + "Value": "pending" + }, + { + "Key": "CONFIRMED", + "Value": "confirmed" + }, + { + "Key": "PREPARING", + "Value": "preparing" + }, + { + "Key": "READY", + "Value": "ready" + }, + { + "Key": "DELIVERED", + "Value": "delivered" + }, + { + "Key": "CANCELLED", + "Value": "cancelled" + } + ], + "Transitions": [ + { + "From": "PENDING", + "To": [ + "CONFIRMED", + "CANCELLED" + ] + }, + { + "From": "CONFIRMED", + "To": [ + "PREPARING", + "CANCELLED" + ] + }, + { + "From": "PREPARING", + "To": [ + "READY", + "CANCELLED" + ] + }, + { + "From": "READY", + "To": [ + "DELIVERED", + "CANCELLED" + ] + } + ] + } + }, + { + "id": "c7df2f28-2db7-44b5-a95f-1f5f85269886", + "type": "Enum", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 2038, + "positionY": 5888, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:46:46.024Z", + "updatedAt": "2026-06-07T15:46:46.024Z", + "version": 1, + "properties": { + "Name": "ReservationStatus", + "Description": "Tracks reservation state", + "BackingType": "string", + "Values": [ + { + "Key": "PENDING", + "Value": "pending" + }, + { + "Key": "CONFIRMED", + "Value": "confirmed" + }, + { + "Key": "SEATED", + "Value": "seated" + }, + { + "Key": "COMPLETED", + "Value": "completed" + }, + { + "Key": "CANCELLED", + "Value": "cancelled" + }, + { + "Key": "NO_SHOW", + "Value": "no_show" + } + ] + } + }, + { + "id": "366edf5d-3f24-4aa7-b0d7-ebacb3c781fe", + "type": "Enum", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 2428, + "positionY": 2433, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:46:46.059Z", + "updatedAt": "2026-06-07T15:46:46.059Z", + "version": 1, + "properties": { + "Name": "UserRole", + "Description": "Defines user access levels", + "BackingType": "string", + "Values": [ + { + "Key": "CUSTOMER", + "Value": "customer" + }, + { + "Key": "RESTAURANT_ADMIN", + "Value": "restaurant_admin" + }, + { + "Key": "PLATFORM_ADMIN", + "Value": "platform_admin" + } + ] + } + }, + { + "id": "f7b411e0-37bb-4be1-b0a6-455a52a2c84e", + "type": "Enum", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 2038, + "positionY": 1583, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:46:46.094Z", + "updatedAt": "2026-06-07T15:46:46.094Z", + "version": 1, + "properties": { + "Name": "CuisineType", + "Description": "Types of cuisine a restaurant can serve", + "BackingType": "string", + "Values": [ + { + "Key": "ITALIAN", + "Value": "italian" + }, + { + "Key": "CHINESE", + "Value": "chinese" + }, + { + "Key": "JAPANESE", + "Value": "japanese" + }, + { + "Key": "MEXICAN", + "Value": "mexican" + }, + { + "Key": "INDIAN", + "Value": "indian" + }, + { + "Key": "FRENCH", + "Value": "french" + }, + { + "Key": "AMERICAN", + "Value": "american" + }, + { + "Key": "MEDITERRANEAN", + "Value": "mediterranean" + }, + { + "Key": "OTHER", + "Value": "other" + } + ] + } + }, + { + "id": "506fea2d-e7ae-4c9f-9cb2-e1cbf0669696", + "type": "Table", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 2028, + "positionY": 2407, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:05.605Z", + "updatedAt": "2026-06-07T15:47:05.605Z", + "version": 1, + "properties": { + "TableName": "Users", + "Description": "Stores user accounts including customers and restaurant admins", + "Columns": [ + { + "Name": "Id", + "DataType": "UUID", + "IsPrimaryKey": true, + "IsNotNull": true, + "IsUnique": true, + "AutoIncrement": false + }, + { + "Name": "Email", + "DataType": "VARCHAR", + "Length": 255, + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": true, + "AutoIncrement": false + }, + { + "Name": "PasswordHash", + "DataType": "VARCHAR", + "Length": 512, + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "FullName", + "DataType": "VARCHAR", + "Length": 200, + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "Role", + "DataType": "ENUM", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false, + "EnumRef": "UserRole" + }, + { + "Name": "PhoneNumber", + "DataType": "VARCHAR", + "Length": 20, + "IsPrimaryKey": false, + "IsNotNull": false, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "CreatedAt", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "UpdatedAt", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + } + ], + "ForeignKeys": [], + "UniqueConstraints": [], + "CheckConstraints": [], + "Indexes": [ + { + "IndexName": "IX_Users_Email", + "Columns": [ + "Email" + ], + "Type": "BTree", + "IsUnique": true + } + ] + } + }, + { + "id": "44618c13-2b8c-4ebf-a538-c01ee3d23732", + "type": "Table", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1628, + "positionY": 1583, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:05.661Z", + "updatedAt": "2026-06-07T15:47:05.661Z", + "version": 1, + "properties": { + "TableName": "Restaurants", + "Description": "Stores restaurant listings with details", + "Columns": [ + { + "Name": "Id", + "DataType": "UUID", + "IsPrimaryKey": true, + "IsNotNull": true, + "IsUnique": true, + "AutoIncrement": false + }, + { + "Name": "Name", + "DataType": "VARCHAR", + "Length": 200, + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "Description", + "DataType": "TEXT", + "IsPrimaryKey": false, + "IsNotNull": false, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "CuisineType", + "DataType": "ENUM", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false, + "EnumRef": "CuisineType" + }, + { + "Name": "Address", + "DataType": "VARCHAR", + "Length": 500, + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "OwnerId", + "DataType": "UUID", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "Rating", + "DataType": "FLOAT", + "IsPrimaryKey": false, + "IsNotNull": false, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "IsActive", + "DataType": "BOOLEAN", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "CreatedAt", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "UpdatedAt", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + } + ], + "ForeignKeys": [ + { + "Columns": [ + "OwnerId" + ], + "ReferencesTable": "Users", + "ReferencesColumns": [ + "Id" + ], + "OnDelete": "RESTRICT", + "OnUpdate": "NO_ACTION" + } + ], + "UniqueConstraints": [], + "CheckConstraints": [], + "Indexes": [] + } + }, + { + "id": "a6b1fa81-a0bf-4cc0-9643-b77cf0c8a532", + "type": "Table", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1628, + "positionY": 6154, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:05.723Z", + "updatedAt": "2026-06-07T15:47:05.723Z", + "version": 1, + "properties": { + "TableName": "MenuItems", + "Description": "Menu items belonging to a restaurant", + "Columns": [ + { + "Name": "Id", + "DataType": "UUID", + "IsPrimaryKey": true, + "IsNotNull": true, + "IsUnique": true, + "AutoIncrement": false + }, + { + "Name": "RestaurantId", + "DataType": "UUID", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "Name", + "DataType": "VARCHAR", + "Length": 200, + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "Description", + "DataType": "TEXT", + "IsPrimaryKey": false, + "IsNotNull": false, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "Price", + "DataType": "DECIMAL", + "Precision": 10, + "Scale": 2, + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "Category", + "DataType": "VARCHAR", + "Length": 100, + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "ImageUrl", + "DataType": "VARCHAR", + "Length": 500, + "IsPrimaryKey": false, + "IsNotNull": false, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "IsAvailable", + "DataType": "BOOLEAN", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "CreatedAt", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "UpdatedAt", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + } + ], + "ForeignKeys": [ + { + "Columns": [ + "RestaurantId" + ], + "ReferencesTable": "Restaurants", + "ReferencesColumns": [ + "Id" + ], + "OnDelete": "CASCADE", + "OnUpdate": "NO_ACTION" + } + ], + "UniqueConstraints": [], + "CheckConstraints": [], + "Indexes": [ + { + "IndexName": "IX_MenuItems_RestaurantId", + "Columns": [ + "RestaurantId" + ], + "Type": "BTree", + "IsUnique": false + } + ] + } + }, + { + "id": "c377a033-3c4f-4ed2-b1e4-90bb109c745e", + "type": "Table", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1628, + "positionY": 5356, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:05.785Z", + "updatedAt": "2026-06-07T15:47:05.785Z", + "version": 1, + "properties": { + "TableName": "Orders", + "Description": "Customer orders placed at restaurants", + "Columns": [ + { + "Name": "Id", + "DataType": "UUID", + "IsPrimaryKey": true, + "IsNotNull": true, + "IsUnique": true, + "AutoIncrement": false + }, + { + "Name": "UserId", + "DataType": "UUID", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "RestaurantId", + "DataType": "UUID", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "Status", + "DataType": "ENUM", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false, + "EnumRef": "OrderStatus" + }, + { + "Name": "TotalAmount", + "DataType": "DECIMAL", + "Precision": 10, + "Scale": 2, + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "Notes", + "DataType": "TEXT", + "IsPrimaryKey": false, + "IsNotNull": false, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "CreatedAt", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + }, + { + "Name": "UpdatedAt", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false + } + ], + "ForeignKeys": [ + { + "Columns": [ + "UserId" + ], + "ReferencesTable": "Users", + "ReferencesColumns": [ + "Id" + ], + "OnDelete": "RESTRICT", + "OnUpdate": "NO_ACTION" + }, + { + "Columns": [ + "RestaurantId" + ], + "ReferencesTable": "Restaurants", + "ReferencesColumns": [ + "Id" + ], + "OnDelete": "RESTRICT", + "OnUpdate": "NO_ACTION" + } + ], + "UniqueConstraints": [], + "CheckConstraints": [], + "Indexes": [ + { + "IndexName": "IX_Orders_UserId", + "Columns": [ + "UserId" + ], + "Type": "BTree", + "IsUnique": false + }, + { + "IndexName": "IX_Orders_RestaurantId", + "Columns": [ + "RestaurantId" + ], + "Type": "BTree", + "IsUnique": false + } + ] + } + }, + { + "id": "a819d7c7-81c1-4cb9-bcbc-f9875e14bbb4", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 838, + "positionY": 965, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:24.898Z", + "updatedAt": "2026-06-07T15:47:24.898Z", + "version": 1, + "properties": { + "Name": "LoginRequest", + "Description": "Payload for user login", + "Fields": [ + { + "Name": "Email", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [ + { + "Rule": "Email" + } + ] + }, + { + "Name": "Password", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [ + { + "Rule": "MinLength", + "Value": "8" + } + ] + } + ] + } + }, + { + "id": "496eead2-d4d1-4525-9317-60d75cb2e97f", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 838, + "positionY": 1153, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:25.114Z", + "updatedAt": "2026-06-07T15:47:25.114Z", + "version": 1, + "properties": { + "Name": "RegisterRequest", + "Description": "Payload for user registration", + "Fields": [ + { + "Name": "Email", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [ + { + "Rule": "Email" + } + ] + }, + { + "Name": "Password", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [ + { + "Rule": "MinLength", + "Value": "8" + } + ] + }, + { + "Name": "FullName", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [ + { + "Rule": "MinLength", + "Value": "2" + } + ] + }, + { + "Name": "PhoneNumber", + "DataType": "VARCHAR", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + } + ] + } + }, + { + "id": "a06cc4ee-71ba-409f-85ef-837f699bc84f", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 838, + "positionY": 1899, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:25.135Z", + "updatedAt": "2026-06-07T15:47:25.135Z", + "version": 1, + "properties": { + "Name": "OrderCreateRequest", + "Description": "Payload to create a new order", + "Fields": [ + { + "Name": "RestaurantId", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Items", + "DataType": "JSON", + "IsRequired": true, + "IsArray": true, + "ValidationRules": [], + "NestedDTORef": "OrderItemRequest" + }, + { + "Name": "Notes", + "DataType": "VARCHAR", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + } + ] + } + }, + { + "id": "c98476b6-ee6f-43ac-bf49-7fd97943fc7e", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 838, + "positionY": 2379, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:25.149Z", + "updatedAt": "2026-06-07T15:47:25.149Z", + "version": 1, + "properties": { + "Name": "OrderItemRequest", + "Description": "Single item within an order request", + "Fields": [ + { + "Name": "MenuItemId", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Quantity", + "DataType": "INT", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [ + { + "Rule": "Positive" + } + ] + } + ] + } + }, + { + "id": "79f7f335-41d8-49f4-84a5-117b67e5b92d", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 838, + "positionY": 3073, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:25.169Z", + "updatedAt": "2026-06-07T15:47:25.169Z", + "version": 1, + "properties": { + "Name": "ReservationRequest", + "Description": "Payload to create a reservation", + "Fields": [ + { + "Name": "RestaurantId", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "ReservationDate", + "DataType": "DATETIME", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "PartySize", + "DataType": "INT", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [ + { + "Rule": "Positive" + } + ] + }, + { + "Name": "Notes", + "DataType": "VARCHAR", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + } + ] + } + }, + { + "id": "a241de15-d05e-435c-8f95-f9e94462b763", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 838, + "positionY": 3767, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:25.198Z", + "updatedAt": "2026-06-07T15:47:25.198Z", + "version": 1, + "properties": { + "Name": "ReviewRequest", + "Description": "Payload to submit a review", + "Fields": [ + { + "Name": "RestaurantId", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Rating", + "DataType": "INT", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [ + { + "Rule": "Min", + "Value": "1" + }, + { + "Rule": "Max", + "Value": "5" + } + ] + }, + { + "Name": "Comment", + "DataType": "TEXT", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + } + ] + } + }, + { + "id": "ef93a957-3b99-41b8-8979-b081a7cbb857", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 838, + "positionY": 1393, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:25.214Z", + "updatedAt": "2026-06-07T15:47:25.214Z", + "version": 1, + "properties": { + "Name": "UserResponse", + "Description": "User data returned to clients", + "Fields": [ + { + "Name": "Id", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Email", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "FullName", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Role", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [], + "EnumRef": "UserRole" + }, + { + "Name": "PhoneNumber", + "DataType": "VARCHAR", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "CreatedAt", + "DataType": "DATETIME", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + } + ] + } + }, + { + "id": "06cbfd54-430a-4e3f-a8c8-15a73c55cc71", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 838, + "positionY": 2113, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:25.222Z", + "updatedAt": "2026-06-07T15:47:25.222Z", + "version": 1, + "properties": { + "Name": "OrderResponse", + "Description": "Order data returned to clients", + "Fields": [ + { + "Name": "Id", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "UserId", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "RestaurantId", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Status", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [], + "EnumRef": "OrderStatus" + }, + { + "Name": "TotalAmount", + "DataType": "DECIMAL", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Items", + "DataType": "JSON", + "IsRequired": true, + "IsArray": true, + "ValidationRules": [], + "NestedDTORef": "OrderItemResponse" + }, + { + "Name": "Notes", + "DataType": "VARCHAR", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "CreatedAt", + "DataType": "DATETIME", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + } + ] + } + }, + { + "id": "05660a68-240d-4bf5-904e-3bfe52d414d7", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 838, + "positionY": 2567, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:25.277Z", + "updatedAt": "2026-06-07T15:47:25.277Z", + "version": 1, + "properties": { + "Name": "OrderItemResponse", + "Description": "Single order item in response", + "Fields": [ + { + "Name": "Id", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "MenuItemId", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "MenuItemName", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Quantity", + "DataType": "INT", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "UnitPrice", + "DataType": "DECIMAL", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + } + ] + } + }, + { + "id": "0a88569c-ede9-4851-972f-33758ada0307", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 838, + "positionY": 5823, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:25.326Z", + "updatedAt": "2026-06-07T15:47:25.326Z", + "version": 1, + "properties": { + "Name": "MenuItemResponse", + "Description": "Menu item data returned to clients", + "Fields": [ + { + "Name": "Id", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "RestaurantId", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Name", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Description", + "DataType": "TEXT", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Price", + "DataType": "DECIMAL", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Category", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "ImageUrl", + "DataType": "VARCHAR", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "IsAvailable", + "DataType": "BOOLEAN", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + } + ] + } + }, + { + "id": "3175404e-6478-420a-8ea3-7e7d95e44ca5", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 838, + "positionY": 288, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:25.418Z", + "updatedAt": "2026-06-07T15:47:25.418Z", + "version": 1, + "properties": { + "Name": "RestaurantResponse", + "Description": "Restaurant data returned to clients", + "Fields": [ + { + "Name": "Id", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Name", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Description", + "DataType": "TEXT", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "CuisineType", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [], + "EnumRef": "CuisineType" + }, + { + "Name": "Address", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Rating", + "DataType": "FLOAT", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "IsActive", + "DataType": "BOOLEAN", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + } + ] + } + }, + { + "id": "a454c7c0-d20a-4399-a9b2-039929a70cfa", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 838, + "positionY": 3313, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:25.487Z", + "updatedAt": "2026-06-07T15:47:25.487Z", + "version": 1, + "properties": { + "Name": "ReservationResponse", + "Description": "Reservation data returned to clients", + "Fields": [ + { + "Name": "Id", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "UserId", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "RestaurantId", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "ReservationDate", + "DataType": "DATETIME", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "PartySize", + "DataType": "INT", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Status", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [], + "EnumRef": "ReservationStatus" + }, + { + "Name": "Notes", + "DataType": "VARCHAR", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "CreatedAt", + "DataType": "DATETIME", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + } + ] + } + }, + { + "id": "7d0a06b5-204f-4d99-8415-8bbe5b6826cd", + "type": "Exception", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1658, + "positionY": 3554, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:38.340Z", + "updatedAt": "2026-06-07T15:47:38.340Z", + "version": 1, + "properties": { + "ExceptionName": "NotFoundException", + "Description": "Thrown when a requested resource is not found", + "HttpStatusCode": 404, + "LogSeverity": "Warning", + "ErrorCode": "NOT_FOUND" + } + }, + { + "id": "5903ce82-a9cd-43a4-bcac-d6f757454e22", + "type": "Exception", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1258, + "positionY": 1101, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:38.379Z", + "updatedAt": "2026-06-07T15:47:38.379Z", + "version": 1, + "properties": { + "ExceptionName": "ValidationException", + "Description": "Thrown when input validation fails", + "HttpStatusCode": 400, + "LogSeverity": "Info", + "ErrorCode": "VALIDATION_ERROR" + } + }, + { + "id": "577b9eb8-2870-45e1-b4d9-834b7a51cdbd", + "type": "Exception", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1258, + "positionY": 615, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:38.390Z", + "updatedAt": "2026-06-07T15:47:38.390Z", + "version": 1, + "properties": { + "ExceptionName": "UnauthorizedException", + "Description": "Thrown when authentication or authorization fails", + "HttpStatusCode": 401, + "LogSeverity": "Warning", + "ErrorCode": "UNAUTHORIZED" + } + }, + { + "id": "f979cdc0-772b-43d7-8619-cdbdc83b08e1", + "type": "Exception", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1258, + "positionY": 2997, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:38.404Z", + "updatedAt": "2026-06-07T15:47:38.404Z", + "version": 1, + "properties": { + "ExceptionName": "ConflictException", + "Description": "Thrown when a resource conflict occurs (e.g. duplicate reservation)", + "HttpStatusCode": 409, + "LogSeverity": "Warning", + "ErrorCode": "CONFLICT" + } + }, + { + "id": "ecf4ac9c-91f4-4fa8-abf7-f5585aef35b9", + "type": "EnvironmentVariable", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1258, + "positionY": 777, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:38.413Z", + "updatedAt": "2026-06-07T15:47:38.413Z", + "version": 1, + "properties": { + "Key": "DB_CONNECTION_STRING", + "Description": "PostgreSQL connection string for the primary database", + "DataType": "String", + "IsSecret": true, + "Environment": [ + "Dev", + "Staging", + "Prod" + ], + "IsRequired": true + } + }, + { + "id": "e1c17afb-4ba5-4fbc-917b-1e8fa6e8ea40", + "type": "EnvironmentVariable", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1258, + "positionY": 939, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:38.443Z", + "updatedAt": "2026-06-07T15:47:38.443Z", + "version": 1, + "properties": { + "Key": "JWT_SECRET", + "Description": "Secret key for signing JWT tokens", + "DataType": "String", + "IsSecret": true, + "Environment": [ + "Dev", + "Staging", + "Prod" + ], + "IsRequired": true + } + }, + { + "id": "2364ad66-f7bc-4fd2-aa2b-ad60c26dcb7d", + "type": "EnvironmentVariable", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1258, + "positionY": 200, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:38.526Z", + "updatedAt": "2026-06-07T15:47:38.526Z", + "version": 1, + "properties": { + "Key": "REDIS_URL", + "Description": "Redis connection URL for caching", + "DataType": "String", + "IsSecret": true, + "Environment": [ + "Dev", + "Staging", + "Prod" + ], + "IsRequired": true + } + }, + { + "id": "0ac8863d-db5c-4294-a938-2e24d60047cb", + "type": "Cache", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1248, + "positionY": 2809, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:38.558Z", + "updatedAt": "2026-06-07T15:47:38.558Z", + "version": 1, + "properties": { + "CacheName": "RedisCache", + "Description": "Redis-based cache for frequently accessed data like restaurant listings and menu items", + "KeyPattern": "restaurant:{entity}:{id}", + "TTL_Seconds": 300, + "Engine": "Redis", + "EvictionPolicy": "LRU", + "MaxSizeMB": 256, + "Serialization": "json" + } + }, + { + "id": "43b706e7-70ac-4915-8500-402b7271d2aa", + "type": "Middleware", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 58, + "positionY": 3193, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:38.606Z", + "updatedAt": "2026-06-07T15:47:38.606Z", + "version": 1, + "properties": { + "MiddlewareName": "JwtAuthMiddleware", + "Description": "Validates JWT tokens and sets user context on protected routes", + "AppliesTo": "SpecificRoutes", + "ExecutionOrder": 1, + "MiddlewareType": "Auth", + "Config": [ + { + "Key": "TokenExpiryHours", + "Value": "24" + } + ] + } + }, + { + "id": "c2e6b671-0fc9-488d-8984-93fb886a75f5", + "type": "Middleware", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 56, + "positionY": 5296, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:38.657Z", + "updatedAt": "2026-06-07T15:47:38.657Z", + "version": 1, + "properties": { + "MiddlewareName": "ErrorHandlerMiddleware", + "Description": "Global exception handler that maps exceptions to HTTP responses", + "AppliesTo": "Global", + "ExecutionOrder": 99, + "MiddlewareType": "ErrorHandler", + "Config": [] + } + }, + { + "id": "ace78904-8e85-42c4-ad84-e38921f30a9c", + "type": "Middleware", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 58, + "positionY": 773, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:47:38.681Z", + "updatedAt": "2026-06-07T15:47:38.681Z", + "version": 1, + "properties": { + "MiddlewareName": "RateLimitMiddleware", + "Description": "Rate limiting per IP/user to prevent abuse", + "AppliesTo": "Global", + "ExecutionOrder": 0, + "MiddlewareType": "RateLimit", + "Config": [ + { + "Key": "MaxRequestsPerMinute", + "Value": "60" + } + ] + } + }, + { + "id": "a9c664ff-dc2c-4680-b685-0ea92dc4f5f8", + "type": "Service", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 828, + "positionY": 777, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:48:00.583Z", + "updatedAt": "2026-06-07T15:48:00.583Z", + "version": 1, + "properties": { + "ServiceName": "AuthService", + "Description": "Handles user authentication, JWT token generation, and registration", + "IsTransactionScoped": true, + "Methods": [ + { + "MethodName": "Login", + "Visibility": "public", + "Parameters": [ + { + "Name": "request", + "Type": "LoginRequest", + "Optional": false, + "DtoRef": "LoginRequest" + } + ], + "ReturnType": "string", + "ReturnDtoRef": "UserResponse", + "IsAsync": true, + "Throws": [ + "UnauthorizedException" + ], + "Description": "Authenticates user and returns JWT token" + }, + { + "MethodName": "Register", + "Visibility": "public", + "Parameters": [ + { + "Name": "request", + "Type": "RegisterRequest", + "Optional": false, + "DtoRef": "RegisterRequest" + } + ], + "ReturnType": "UserResponse", + "ReturnDtoRef": "UserResponse", + "IsAsync": true, + "Throws": [ + "ValidationException", + "ConflictException" + ], + "Description": "Registers a new user account" + } + ], + "Dependencies": [ + { + "Kind": "Repository", + "Ref": "UserRepository" + }, + { + "Kind": "Service", + "Ref": "UserService" + } + ] + } + }, + { + "id": "197648e1-e59b-495f-ab34-b72032be608a", + "type": "Service", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 828, + "positionY": 1659, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:48:00.816Z", + "updatedAt": "2026-06-07T15:48:00.816Z", + "version": 1, + "properties": { + "ServiceName": "OrderService", + "Description": "Manages order lifecycle from creation to delivery", + "IsTransactionScoped": true, + "Methods": [ + { + "MethodName": "Create", + "Visibility": "public", + "Parameters": [ + { + "Name": "userId", + "Type": "UUID", + "Optional": false + }, + { + "Name": "request", + "Type": "OrderCreateRequest", + "Optional": false, + "DtoRef": "OrderCreateRequest" + } + ], + "ReturnType": "OrderResponse", + "ReturnDtoRef": "OrderResponse", + "IsAsync": true, + "Throws": [ + "ValidationException", + "NotFoundException" + ], + "Description": "Places a new order" + }, + { + "MethodName": "GetById", + "Visibility": "public", + "Parameters": [ + { + "Name": "orderId", + "Type": "UUID", + "Optional": false + } + ], + "ReturnType": "OrderResponse", + "ReturnDtoRef": "OrderResponse", + "IsAsync": true, + "Throws": [ + "NotFoundException" + ], + "Description": "Retrieves order details" + }, + { + "MethodName": "GetByUserId", + "Visibility": "public", + "Parameters": [ + { + "Name": "userId", + "Type": "UUID", + "Optional": false + } + ], + "ReturnType": "array", + "ReturnDtoRef": "OrderResponse", + "IsAsync": true, + "Throws": [], + "Description": "Lists orders for a user" + }, + { + "MethodName": "UpdateStatus", + "Visibility": "public", + "Parameters": [ + { + "Name": "orderId", + "Type": "UUID", + "Optional": false + }, + { + "Name": "status", + "Type": "OrderStatus", + "Optional": false + } + ], + "ReturnType": "OrderResponse", + "ReturnDtoRef": "OrderResponse", + "IsAsync": true, + "Throws": [ + "NotFoundException", + "ValidationException" + ], + "Description": "Updates order status" + } + ], + "Dependencies": [ + { + "Kind": "Repository", + "Ref": "OrderRepository" + }, + { + "Kind": "Repository", + "Ref": "MenuItemRepository" + } + ] + } + }, + { + "id": "08f77d07-e08a-45dc-83aa-bdb10d761abc", + "type": "Service", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 828, + "positionY": 2833, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:48:00.888Z", + "updatedAt": "2026-06-07T15:48:00.888Z", + "version": 1, + "properties": { + "ServiceName": "ReservationService", + "Description": "Manages table reservations", + "IsTransactionScoped": true, + "Methods": [ + { + "MethodName": "Create", + "Visibility": "public", + "Parameters": [ + { + "Name": "userId", + "Type": "UUID", + "Optional": false + }, + { + "Name": "request", + "Type": "ReservationRequest", + "Optional": false, + "DtoRef": "ReservationRequest" + } + ], + "ReturnType": "ReservationResponse", + "ReturnDtoRef": "ReservationResponse", + "IsAsync": true, + "Throws": [ + "ValidationException", + "ConflictException", + "NotFoundException" + ], + "Description": "Creates a new reservation" + }, + { + "MethodName": "GetById", + "Visibility": "public", + "Parameters": [ + { + "Name": "reservationId", + "Type": "UUID", + "Optional": false + } + ], + "ReturnType": "ReservationResponse", + "ReturnDtoRef": "ReservationResponse", + "IsAsync": true, + "Throws": [ + "NotFoundException" + ], + "Description": "Retrieves reservation details" + }, + { + "MethodName": "GetByUserId", + "Visibility": "public", + "Parameters": [ + { + "Name": "userId", + "Type": "UUID", + "Optional": false + } + ], + "ReturnType": "array", + "ReturnDtoRef": "ReservationResponse", + "IsAsync": true, + "Throws": [], + "Description": "Lists reservations for a user" + }, + { + "MethodName": "UpdateStatus", + "Visibility": "public", + "Parameters": [ + { + "Name": "reservationId", + "Type": "UUID", + "Optional": false + }, + { + "Name": "status", + "Type": "ReservationStatus", + "Optional": false + } + ], + "ReturnType": "ReservationResponse", + "ReturnDtoRef": "ReservationResponse", + "IsAsync": true, + "Throws": [ + "NotFoundException", + "ValidationException" + ], + "Description": "Updates reservation status" + } + ], + "Dependencies": [ + { + "Kind": "Repository", + "Ref": "ReservationRepository" + } + ] + } + }, + { + "id": "9ca5296d-dc30-4c38-be62-fb77f228393c", + "type": "Service", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 828, + "positionY": 3579, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:48:00.956Z", + "updatedAt": "2026-06-07T15:48:00.956Z", + "version": 1, + "properties": { + "ServiceName": "ReviewService", + "Description": "Manages restaurant reviews and ratings", + "IsTransactionScoped": false, + "Methods": [ + { + "MethodName": "Create", + "Visibility": "public", + "Parameters": [ + { + "Name": "userId", + "Type": "UUID", + "Optional": false + }, + { + "Name": "request", + "Type": "ReviewRequest", + "Optional": false, + "DtoRef": "ReviewRequest" + } + ], + "ReturnType": "ReviewResponse", + "ReturnDtoRef": "ReviewResponse", + "IsAsync": true, + "Throws": [ + "ValidationException", + "ConflictException", + "NotFoundException" + ], + "Description": "Submits a restaurant review" + }, + { + "MethodName": "GetByRestaurantId", + "Visibility": "public", + "Parameters": [ + { + "Name": "restaurantId", + "Type": "UUID", + "Optional": false + } + ], + "ReturnType": "array", + "ReturnDtoRef": "ReviewResponse", + "IsAsync": true, + "Throws": [], + "Description": "Lists reviews for a restaurant" + } + ], + "Dependencies": [ + { + "Kind": "Repository", + "Ref": "ReviewRepository" + }, + { + "Kind": "Repository", + "Ref": "RestaurantRepository" + } + ] + } + }, + { + "id": "c2c35f15-9e60-42d7-b928-56cc548da39d", + "type": "Repository", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1648, + "positionY": 2459, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:48:13.477Z", + "updatedAt": "2026-06-07T15:48:13.477Z", + "version": 1, + "properties": { + "RepositoryName": "UserRepository", + "Description": "Data access for Users table", + "EntityReference": "Users", + "BaseClass": "BaseRepository", + "IsCached": false, + "CustomQueries": [ + { + "QueryName": "findByEmail", + "QueryType": "findOne", + "Parameters": [ + { + "Name": "email", + "Type": "VARCHAR" + } + ], + "ReturnType": "User" + } + ] + } + }, + { + "id": "9bc491d9-80f9-4f04-815e-e9e9dd3919ce", + "type": "Repository", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1248, + "positionY": 2433, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:48:13.506Z", + "updatedAt": "2026-06-07T15:48:13.506Z", + "version": 1, + "properties": { + "RepositoryName": "RestaurantRepository", + "Description": "Data access for Restaurants table", + "EntityReference": "Restaurants", + "BaseClass": "BaseRepository", + "IsCached": true, + "CustomQueries": [ + { + "QueryName": "findByCuisineType", + "QueryType": "find", + "Parameters": [ + { + "Name": "cuisineType", + "Type": "CuisineType" + } + ], + "ReturnType": "Restaurant" + }, + { + "QueryName": "searchByKeyword", + "QueryType": "find", + "Parameters": [ + { + "Name": "keyword", + "Type": "VARCHAR" + } + ], + "ReturnType": "Restaurant" + } + ] + } + }, + { + "id": "2c194d94-65bc-46a3-b4c4-429d6d0727d4", + "type": "Repository", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1248, + "positionY": 5290, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:48:13.516Z", + "updatedAt": "2026-06-07T15:48:13.516Z", + "version": 1, + "properties": { + "RepositoryName": "MenuItemRepository", + "Description": "Data access for MenuItems table", + "EntityReference": "MenuItems", + "BaseClass": "BaseRepository", + "IsCached": true, + "CustomQueries": [ + { + "QueryName": "findByRestaurantId", + "QueryType": "find", + "Parameters": [ + { + "Name": "restaurantId", + "Type": "UUID" + } + ], + "ReturnType": "MenuItem" + } + ] + } + }, + { + "id": "ee38301d-4ecd-4c3c-8e29-5cd9d68a787f", + "type": "Repository", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1248, + "positionY": 4862, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:48:13.524Z", + "updatedAt": "2026-06-07T15:48:13.524Z", + "version": 1, + "properties": { + "RepositoryName": "OrderRepository", + "Description": "Data access for Orders and OrderItems tables", + "EntityReference": "Orders", + "BaseClass": "BaseRepository", + "IsCached": false, + "CustomQueries": [ + { + "QueryName": "findByUserId", + "QueryType": "find", + "Parameters": [ + { + "Name": "userId", + "Type": "UUID" + } + ], + "ReturnType": "Order" + }, + { + "QueryName": "findByRestaurantId", + "QueryType": "find", + "Parameters": [ + { + "Name": "restaurantId", + "Type": "UUID" + } + ], + "ReturnType": "Order" + } + ] + } + }, + { + "id": "9193c1a2-ba3f-4516-87d5-579a2e0dbb20", + "type": "Repository", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1248, + "positionY": 5076, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:48:13.537Z", + "updatedAt": "2026-06-07T15:48:13.537Z", + "version": 1, + "properties": { + "RepositoryName": "ReservationRepository", + "Description": "Data access for Reservations table", + "EntityReference": "Reservations", + "BaseClass": "BaseRepository", + "IsCached": false, + "CustomQueries": [ + { + "QueryName": "findByUserId", + "QueryType": "find", + "Parameters": [ + { + "Name": "userId", + "Type": "UUID" + } + ], + "ReturnType": "Reservation" + }, + { + "QueryName": "findConflicting", + "QueryType": "find", + "Parameters": [ + { + "Name": "restaurantId", + "Type": "UUID" + }, + { + "Name": "reservationDate", + "Type": "DATETIME" + } + ], + "ReturnType": "Reservation" + } + ] + } + }, + { + "id": "b7d1eec7-0624-4fae-ad44-3358056e3093", + "type": "Repository", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1248, + "positionY": 2647, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:48:13.546Z", + "updatedAt": "2026-06-07T15:48:13.546Z", + "version": 1, + "properties": { + "RepositoryName": "ReviewRepository", + "Description": "Data access for Reviews table", + "EntityReference": "Reviews", + "BaseClass": "BaseRepository", + "IsCached": false, + "CustomQueries": [ + { + "QueryName": "findByRestaurantId", + "QueryType": "find", + "Parameters": [ + { + "Name": "restaurantId", + "Type": "UUID" + } + ], + "ReturnType": "Review" + }, + { + "QueryName": "findByUserAndRestaurant", + "QueryType": "findOne", + "Parameters": [ + { + "Name": "userId", + "Type": "UUID" + }, + { + "Name": "restaurantId", + "Type": "UUID" + } + ], + "ReturnType": "Review" + } + ] + } + }, + { + "id": "00f0232d-5c39-403b-9c48-bb193ce85a64", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 838, + "positionY": 3981, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:48:13.561Z", + "updatedAt": "2026-06-07T15:48:13.561Z", + "version": 1, + "properties": { + "Name": "ReviewResponse", + "Description": "Review data returned to clients", + "Fields": [ + { + "Name": "Id", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "UserId", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "RestaurantId", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Rating", + "DataType": "INT", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Comment", + "DataType": "TEXT", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "CreatedAt", + "DataType": "DATETIME", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + } + ] + } + }, + { + "id": "973fcea7-1599-47c6-adad-b9d37bd3a9d7", + "type": "Controller", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 428, + "positionY": 1105, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:49:15.098Z", + "updatedAt": "2026-06-07T15:49:15.098Z", + "version": 1, + "properties": { + "ControllerName": "AuthController", + "Description": "Handles authentication endpoints (login, register)", + "BaseRoute": "/api/auth", + "Version": "v1", + "Endpoints": [ + { + "HttpMethod": "POST", + "Route": "/login", + "RequestDTORef": "LoginRequest", + "ResponseDTORef": "UserResponse", + "RequiresAuth": false, + "RequiredRoles": [], + "PathParams": [], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "Login successful, returns JWT token" + }, + { + "Code": 401, + "Description": "Invalid credentials" + } + ], + "MiddlewareRefs": [], + "Description": "Authenticates a user and returns a JWT token" + }, + { + "HttpMethod": "POST", + "Route": "/register", + "RequestDTORef": "RegisterRequest", + "ResponseDTORef": "UserResponse", + "RequiresAuth": false, + "RequiredRoles": [], + "PathParams": [], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 201, + "Description": "User registered successfully" + }, + { + "Code": 400, + "Description": "Validation error" + }, + { + "Code": 409, + "Description": "Email already exists" + } + ], + "MiddlewareRefs": [], + "Description": "Registers a new user account" + } + ] + } + }, + { + "id": "80d1ee01-a949-40e0-9c3f-d869af612cd0", + "type": "Controller", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 428, + "positionY": 175, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:49:33.429Z", + "updatedAt": "2026-06-07T15:49:33.429Z", + "version": 1, + "properties": { + "ControllerName": "RestaurantController", + "Description": "Restaurant listing and search endpoints", + "BaseRoute": "/api/restaurants", + "Version": "v1", + "Endpoints": [ + { + "HttpMethod": "GET", + "Route": "/", + "ResponseDTORef": "RestaurantResponse", + "RequiresAuth": false, + "RequiredRoles": [], + "PathParams": [], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "List of restaurants" + } + ], + "MiddlewareRefs": [], + "Description": "Lists all active restaurants" + }, + { + "HttpMethod": "GET", + "Route": "/{id}", + "ResponseDTORef": "RestaurantResponse", + "RequiresAuth": false, + "RequiredRoles": [], + "PathParams": [ + { + "Name": "id", + "Type": "UUID" + } + ], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "Restaurant details" + }, + { + "Code": 404, + "Description": "Restaurant not found" + } + ], + "MiddlewareRefs": [], + "Description": "Retrieves a specific restaurant" + }, + { + "HttpMethod": "GET", + "Route": "/search", + "ResponseDTORef": "RestaurantResponse", + "RequiresAuth": false, + "RequiredRoles": [], + "PathParams": [], + "QueryParams": [ + { + "Name": "cuisineType", + "Type": "VARCHAR", + "Required": false + }, + { + "Name": "keyword", + "Type": "VARCHAR", + "Required": false + } + ], + "StatusCodes": [ + { + "Code": 200, + "Description": "Search results" + } + ], + "MiddlewareRefs": [], + "Description": "Searches restaurants by cuisine or keyword" + }, + { + "HttpMethod": "POST", + "Route": "/", + "RequestDTORef": "RestaurantResponse", + "ResponseDTORef": "RestaurantResponse", + "RequiresAuth": true, + "RequiredRoles": [ + "RESTAURANT_ADMIN", + "PLATFORM_ADMIN" + ], + "PathParams": [], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 201, + "Description": "Restaurant created" + }, + { + "Code": 400, + "Description": "Validation error" + } + ], + "MiddlewareRefs": [], + "Description": "Creates a new restaurant" + } + ] + } + }, + { + "id": "6a89ff3c-c6c8-46ee-9019-a4069843d627", + "type": "Controller", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 428, + "positionY": 5729, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:49:33.501Z", + "updatedAt": "2026-06-07T15:49:33.501Z", + "version": 1, + "properties": { + "ControllerName": "MenuController", + "Description": "Menu item endpoints", + "BaseRoute": "/api/menu", + "Version": "v1", + "Endpoints": [ + { + "HttpMethod": "GET", + "Route": "/restaurant/{restaurantId}", + "ResponseDTORef": "MenuItemResponse", + "RequiresAuth": false, + "RequiredRoles": [], + "PathParams": [ + { + "Name": "restaurantId", + "Type": "UUID" + } + ], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "Menu items for the restaurant" + } + ], + "MiddlewareRefs": [], + "Description": "Lists menu items for a restaurant" + }, + { + "HttpMethod": "GET", + "Route": "/{id}", + "ResponseDTORef": "MenuItemResponse", + "RequiresAuth": false, + "RequiredRoles": [], + "PathParams": [ + { + "Name": "id", + "Type": "UUID" + } + ], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "Menu item details" + }, + { + "Code": 404, + "Description": "Not found" + } + ], + "MiddlewareRefs": [], + "Description": "Retrieves a single menu item" + }, + { + "HttpMethod": "POST", + "Route": "/", + "RequestDTORef": "MenuItemResponse", + "ResponseDTORef": "MenuItemResponse", + "RequiresAuth": true, + "RequiredRoles": [ + "RESTAURANT_ADMIN", + "PLATFORM_ADMIN" + ], + "PathParams": [], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 201, + "Description": "Menu item created" + }, + { + "Code": 400, + "Description": "Validation error" + } + ], + "MiddlewareRefs": [], + "Description": "Adds a new menu item" + } + ] + } + }, + { + "id": "9635ae7d-ba0e-40b8-af87-dfea9b4d3868", + "type": "Controller", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 428, + "positionY": 2120, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:49:33.560Z", + "updatedAt": "2026-06-07T15:49:33.560Z", + "version": 1, + "properties": { + "ControllerName": "OrderController", + "Description": "Order management endpoints", + "BaseRoute": "/api/orders", + "Version": "v1", + "Endpoints": [ + { + "HttpMethod": "POST", + "Route": "/", + "RequestDTORef": "OrderCreateRequest", + "ResponseDTORef": "OrderResponse", + "RequiresAuth": true, + "RequiredRoles": [], + "PathParams": [], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 201, + "Description": "Order created" + }, + { + "Code": 400, + "Description": "Validation error" + }, + { + "Code": 404, + "Description": "Restaurant or menu item not found" + } + ], + "MiddlewareRefs": [], + "Description": "Places a new order" + }, + { + "HttpMethod": "GET", + "Route": "/{id}", + "ResponseDTORef": "OrderResponse", + "RequiresAuth": true, + "RequiredRoles": [], + "PathParams": [ + { + "Name": "id", + "Type": "UUID" + } + ], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "Order details" + }, + { + "Code": 404, + "Description": "Not found" + } + ], + "MiddlewareRefs": [], + "Description": "Retrieves order details" + }, + { + "HttpMethod": "GET", + "Route": "/user/{userId}", + "ResponseDTORef": "OrderResponse", + "RequiresAuth": true, + "RequiredRoles": [], + "PathParams": [ + { + "Name": "userId", + "Type": "UUID" + } + ], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "User orders" + } + ], + "MiddlewareRefs": [], + "Description": "Lists orders for a user" + }, + { + "HttpMethod": "PATCH", + "Route": "/{id}/status", + "ResponseDTORef": "OrderResponse", + "RequiresAuth": true, + "RequiredRoles": [ + "RESTAURANT_ADMIN", + "PLATFORM_ADMIN" + ], + "PathParams": [ + { + "Name": "id", + "Type": "UUID" + } + ], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "Status updated" + }, + { + "Code": 404, + "Description": "Not found" + } + ], + "MiddlewareRefs": [], + "Description": "Updates order status" + } + ] + } + }, + { + "id": "367c0ff6-9aa7-4538-a85e-ef4502ba2b6f", + "type": "Controller", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 428, + "positionY": 3080, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:49:33.615Z", + "updatedAt": "2026-06-07T15:49:33.615Z", + "version": 1, + "properties": { + "ControllerName": "ReservationController", + "Description": "Reservation management endpoints", + "BaseRoute": "/api/reservations", + "Version": "v1", + "Endpoints": [ + { + "HttpMethod": "POST", + "Route": "/", + "RequestDTORef": "ReservationRequest", + "ResponseDTORef": "ReservationResponse", + "RequiresAuth": true, + "RequiredRoles": [], + "PathParams": [], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 201, + "Description": "Reservation created" + }, + { + "Code": 400, + "Description": "Validation error" + }, + { + "Code": 409, + "Description": "Time slot conflict" + } + ], + "MiddlewareRefs": [], + "Description": "Creates a new reservation" + }, + { + "HttpMethod": "GET", + "Route": "/{id}", + "ResponseDTORef": "ReservationResponse", + "RequiresAuth": true, + "RequiredRoles": [], + "PathParams": [ + { + "Name": "id", + "Type": "UUID" + } + ], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "Reservation details" + }, + { + "Code": 404, + "Description": "Not found" + } + ], + "MiddlewareRefs": [], + "Description": "Retrieves reservation details" + }, + { + "HttpMethod": "GET", + "Route": "/user/{userId}", + "ResponseDTORef": "ReservationResponse", + "RequiresAuth": true, + "RequiredRoles": [], + "PathParams": [ + { + "Name": "userId", + "Type": "UUID" + } + ], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "User reservations" + } + ], + "MiddlewareRefs": [], + "Description": "Lists reservations for a user" + }, + { + "HttpMethod": "PATCH", + "Route": "/{id}/status", + "ResponseDTORef": "ReservationResponse", + "RequiresAuth": true, + "RequiredRoles": [ + "RESTAURANT_ADMIN", + "PLATFORM_ADMIN" + ], + "PathParams": [ + { + "Name": "id", + "Type": "UUID" + } + ], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "Status updated" + }, + { + "Code": 404, + "Description": "Not found" + } + ], + "MiddlewareRefs": [], + "Description": "Updates reservation status" + } + ] + } + }, + { + "id": "2dbcde72-f38c-4584-8665-127a69cbde80", + "type": "Controller", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 428, + "positionY": 3800, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-07T15:49:33.666Z", + "updatedAt": "2026-06-07T15:49:33.666Z", + "version": 1, + "properties": { + "ControllerName": "ReviewController", + "Description": "Review management endpoints", + "BaseRoute": "/api/reviews", + "Version": "v1", + "Endpoints": [ + { + "HttpMethod": "POST", + "Route": "/", + "RequestDTORef": "ReviewRequest", + "ResponseDTORef": "ReviewResponse", + "RequiresAuth": true, + "RequiredRoles": [], + "PathParams": [], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 201, + "Description": "Review submitted" + }, + { + "Code": 400, + "Description": "Validation error" + }, + { + "Code": 409, + "Description": "Already reviewed" + } + ], + "MiddlewareRefs": [], + "Description": "Submits a restaurant review" + }, + { + "HttpMethod": "GET", + "Route": "/restaurant/{restaurantId}", + "ResponseDTORef": "ReviewResponse", + "RequiresAuth": false, + "RequiredRoles": [], + "PathParams": [ + { + "Name": "restaurantId", + "Type": "UUID" + } + ], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "Reviews for the restaurant" + } + ], + "MiddlewareRefs": [], + "Description": "Lists reviews for a restaurant" + } + ] + } + }, + { + "id": "0923c560-3e38-4bb9-a380-c408eb8623d6", + "type": "Enum", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 2038, + "positionY": 2852, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-12T15:34:06.444Z", + "updatedAt": "2026-06-12T15:34:06.444Z", + "version": 1, + "properties": { + "Name": "ComplaintStatus", + "Description": "Status of a user complaint", + "BackingType": "string", + "Values": [ + { + "Key": "PENDING", + "Value": "pending" + }, + { + "Key": "IN_REVIEW", + "Value": "in_review" + }, + { + "Key": "RESOLVED", + "Value": "resolved" + }, + { + "Key": "DISMISSED", + "Value": "dismissed" + } + ] + } + }, + { + "id": "c5788d15-95cb-4bf9-a47e-f5ab389d6dec", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 838, + "positionY": 4247, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-12T15:34:06.452Z", + "updatedAt": "2026-06-12T15:34:06.452Z", + "version": 1, + "properties": { + "Name": "ComplaintRequest", + "Description": "Request payload for filing a new complaint", + "Fields": [ + { + "Name": "RestaurantId", + "DataType": "UUID", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "OrderId", + "DataType": "UUID", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "ReservationId", + "DataType": "UUID", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Subject", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [ + { + "Rule": "MinLength", + "Value": "5" + }, + { + "Rule": "MaxLength", + "Value": "255" + } + ] + }, + { + "Name": "Description", + "DataType": "TEXT", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [ + { + "Rule": "MinLength", + "Value": "20" + }, + { + "Rule": "MaxLength", + "Value": "5000" + } + ] + } + ] + } + }, + { + "id": "5bc8d6c0-afd5-40e9-bed4-a50367e78654", + "type": "DTO", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1638, + "positionY": 2839, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-12T15:34:06.457Z", + "updatedAt": "2026-06-12T15:34:06.457Z", + "version": 1, + "properties": { + "Name": "ComplaintResponse", + "Description": "Response payload for complaint data returned to clients", + "Fields": [ + { + "Name": "Id", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "UserId", + "DataType": "UUID", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "RestaurantId", + "DataType": "UUID", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "OrderId", + "DataType": "UUID", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "ReservationId", + "DataType": "UUID", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Subject", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Description", + "DataType": "TEXT", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "Status", + "DataType": "VARCHAR", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [], + "EnumRef": "ComplaintStatus" + }, + { + "Name": "AdminNotes", + "DataType": "TEXT", + "IsRequired": false, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "CreatedAt", + "DataType": "DATETIME", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + }, + { + "Name": "UpdatedAt", + "DataType": "DATETIME", + "IsRequired": true, + "IsArray": false, + "ValidationRules": [] + } + ] + } + }, + { + "id": "1bb2c6d2-37f3-4e98-ad1e-7e0a61aceb9e", + "type": "Service", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 828, + "positionY": 5101, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-12T15:34:06.513Z", + "updatedAt": "2026-06-12T15:34:06.513Z", + "version": 1, + "properties": { + "ServiceName": "ComplaintService", + "Description": "Handles complaint business logic: filing, reviewing, resolving, and dismissing complaints", + "IsTransactionScoped": true, + "Methods": [ + { + "MethodName": "FileComplaint", + "Visibility": "public", + "Parameters": [ + { + "Name": "userId", + "Type": "UUID", + "Optional": false + }, + { + "Name": "request", + "Type": "ComplaintRequest", + "Optional": false, + "DtoRef": "ComplaintRequest" + } + ], + "ReturnType": "ComplaintResponse", + "ReturnDtoRef": "ComplaintResponse", + "IsAsync": true, + "Throws": [ + "ValidationException", + "NotFoundException", + "ConflictException" + ], + "Description": "Files a new complaint by a user" + }, + { + "MethodName": "GetUserComplaints", + "Visibility": "public", + "Parameters": [ + { + "Name": "userId", + "Type": "UUID", + "Optional": false + } + ], + "ReturnType": "List", + "ReturnDtoRef": "ComplaintResponse", + "IsAsync": true, + "Throws": [], + "Description": "Retrieves all complaints filed by a specific user" + }, + { + "MethodName": "GetComplaintById", + "Visibility": "public", + "Parameters": [ + { + "Name": "complaintId", + "Type": "UUID", + "Optional": false + } + ], + "ReturnType": "ComplaintResponse", + "ReturnDtoRef": "ComplaintResponse", + "IsAsync": true, + "Throws": [ + "NotFoundException" + ], + "Description": "Retrieves a single complaint by its ID" + }, + { + "MethodName": "GetRestaurantComplaints", + "Visibility": "public", + "Parameters": [ + { + "Name": "restaurantId", + "Type": "UUID", + "Optional": false + } + ], + "ReturnType": "List", + "ReturnDtoRef": "ComplaintResponse", + "IsAsync": true, + "Throws": [], + "Description": "Retrieves all complaints for a specific restaurant" + }, + { + "MethodName": "UpdateComplaintStatus", + "Visibility": "public", + "Parameters": [ + { + "Name": "complaintId", + "Type": "UUID", + "Optional": false + }, + { + "Name": "status", + "Type": "ComplaintStatus", + "Optional": false + }, + { + "Name": "adminNotes", + "Type": "string", + "Optional": true + } + ], + "ReturnType": "ComplaintResponse", + "ReturnDtoRef": "ComplaintResponse", + "IsAsync": true, + "Throws": [ + "NotFoundException", + "ValidationException" + ], + "Description": "Updates the status of a complaint (admin action)" + } + ], + "Dependencies": [ + { + "Kind": "Repository", + "Ref": "ComplaintRepository" + }, + { + "Kind": "Repository", + "Ref": "UserRepository" + }, + { + "Kind": "Repository", + "Ref": "RestaurantRepository" + }, + { + "Kind": "Repository", + "Ref": "OrderRepository" + }, + { + "Kind": "Repository", + "Ref": "ReservationRepository" + } + ] + } + }, + { + "id": "8166cfaf-8363-4110-b2a6-7e7fc3437bb9", + "type": "Repository", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1248, + "positionY": 4624, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-12T15:34:06.561Z", + "updatedAt": "2026-06-12T15:34:06.561Z", + "version": 1, + "properties": { + "RepositoryName": "ComplaintRepository", + "Description": "Data access layer for Complaints table", + "EntityReference": "Complaints", + "BaseClass": "GenericRepository", + "IsCached": false, + "CustomQueries": [ + { + "QueryName": "FindByUserId", + "QueryType": "find", + "Parameters": [ + { + "Name": "userId", + "Type": "UUID" + } + ], + "ReturnType": "List" + }, + { + "QueryName": "FindByRestaurantId", + "QueryType": "find", + "Parameters": [ + { + "Name": "restaurantId", + "Type": "UUID" + } + ], + "ReturnType": "List" + }, + { + "QueryName": "FindByStatus", + "QueryType": "find", + "Parameters": [ + { + "Name": "status", + "Type": "ComplaintStatus" + } + ], + "ReturnType": "List" + } + ] + } + }, + { + "id": "c332284b-e9a6-4d9f-8b2f-e443bd7f6db3", + "type": "Table", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 1628, + "positionY": 3105, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-12T15:34:19.225Z", + "updatedAt": "2026-06-12T15:34:19.225Z", + "version": 1, + "properties": { + "TableName": "Complaints", + "Description": "Stores user complaints about restaurants, orders, or reservations", + "Columns": [ + { + "Name": "Id", + "DataType": "UUID", + "IsPrimaryKey": true, + "IsNotNull": true, + "IsUnique": true, + "AutoIncrement": false, + "Comment": "Unique complaint identifier" + }, + { + "Name": "UserId", + "DataType": "UUID", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false, + "Comment": "FK to Users table" + }, + { + "Name": "RestaurantId", + "DataType": "UUID", + "IsPrimaryKey": false, + "IsNotNull": false, + "IsUnique": false, + "AutoIncrement": false, + "Comment": "FK to Restaurants table" + }, + { + "Name": "OrderId", + "DataType": "UUID", + "IsPrimaryKey": false, + "IsNotNull": false, + "IsUnique": false, + "AutoIncrement": false, + "Comment": "FK to Orders table" + }, + { + "Name": "ReservationId", + "DataType": "UUID", + "IsPrimaryKey": false, + "IsNotNull": false, + "IsUnique": false, + "AutoIncrement": false, + "Comment": "FK to Reservations table" + }, + { + "Name": "Subject", + "DataType": "VARCHAR", + "Length": 255, + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false, + "Comment": "Short subject of the complaint" + }, + { + "Name": "Description", + "DataType": "TEXT", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false, + "Comment": "Detailed complaint description" + }, + { + "Name": "Status", + "DataType": "ENUM", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false, + "Comment": "Current status", + "EnumRef": "ComplaintStatus" + }, + { + "Name": "AdminNotes", + "DataType": "TEXT", + "IsPrimaryKey": false, + "IsNotNull": false, + "IsUnique": false, + "AutoIncrement": false, + "Comment": "Internal admin notes" + }, + { + "Name": "CreatedAt", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false, + "Comment": "Timestamp when filed" + }, + { + "Name": "UpdatedAt", + "DataType": "DATETIME", + "IsPrimaryKey": false, + "IsNotNull": true, + "IsUnique": false, + "AutoIncrement": false, + "Comment": "Timestamp of last change" + } + ], + "ForeignKeys": [ + { + "Columns": [ + "UserId" + ], + "ReferencesTable": "Users", + "ReferencesColumns": [ + "Id" + ], + "OnDelete": "CASCADE", + "OnUpdate": "NO_ACTION" + }, + { + "Columns": [ + "RestaurantId" + ], + "ReferencesTable": "Restaurants", + "ReferencesColumns": [ + "Id" + ], + "OnDelete": "SET_NULL", + "OnUpdate": "NO_ACTION" + }, + { + "Columns": [ + "OrderId" + ], + "ReferencesTable": "Orders", + "ReferencesColumns": [ + "Id" + ], + "OnDelete": "SET_NULL", + "OnUpdate": "NO_ACTION" + }, + { + "Columns": [ + "ReservationId" + ], + "ReferencesTable": "Reservations", + "ReferencesColumns": [ + "Id" + ], + "OnDelete": "SET_NULL", + "OnUpdate": "NO_ACTION" + } + ], + "UniqueConstraints": [], + "CheckConstraints": [], + "Indexes": [ + { + "IndexName": "IX_Complaints_UserId", + "Columns": [ + "UserId" + ], + "Type": "BTree", + "IsUnique": false + }, + { + "IndexName": "IX_Complaints_Status", + "Columns": [ + "Status" + ], + "Type": "BTree", + "IsUnique": false + }, + { + "IndexName": "IX_Complaints_RestaurantId", + "Columns": [ + "RestaurantId" + ], + "Type": "BTree", + "IsUnique": false + } + ] + } + }, + { + "id": "33a6b81b-08ba-4780-95b6-773997e4bd0f", + "type": "Controller", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "positionX": 428, + "positionY": 4684, + "homeTabId": "a2bc8dc5-d8f7-48e3-927d-89b16761871d", + "createdAt": "2026-06-12T15:34:19.275Z", + "updatedAt": "2026-06-12T15:34:19.275Z", + "version": 1, + "properties": { + "ControllerName": "ComplaintController", + "Description": "REST endpoints for complaint management", + "BaseRoute": "/api/complaints", + "Version": "v1", + "Endpoints": [ + { + "HttpMethod": "POST", + "Route": "/", + "RequestDTORef": "ComplaintRequest", + "ResponseDTORef": "ComplaintResponse", + "RequiresAuth": true, + "RequiredRoles": [ + "User" + ], + "PathParams": [], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 201, + "Description": "Complaint filed successfully" + }, + { + "Code": 400, + "Description": "Validation error" + }, + { + "Code": 404, + "Description": "Referenced entity not found" + }, + { + "Code": 409, + "Description": "Duplicate complaint" + } + ], + "MiddlewareRefs": [], + "Description": "File a new complaint" + }, + { + "HttpMethod": "GET", + "Route": "/me", + "RequestDTORef": "", + "ResponseDTORef": "ComplaintResponse", + "RequiresAuth": true, + "RequiredRoles": [ + "User" + ], + "PathParams": [], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "User complaints retrieved" + } + ], + "MiddlewareRefs": [], + "Description": "Get current user's complaints" + }, + { + "HttpMethod": "GET", + "Route": "/{complaintId}", + "RequestDTORef": "", + "ResponseDTORef": "ComplaintResponse", + "RequiresAuth": true, + "RequiredRoles": [ + "User", + "Admin" + ], + "PathParams": [ + { + "Name": "complaintId", + "Type": "UUID" + } + ], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "Complaint details" + }, + { + "Code": 404, + "Description": "Complaint not found" + } + ], + "MiddlewareRefs": [], + "Description": "Get complaint by ID" + }, + { + "HttpMethod": "GET", + "Route": "/restaurant/{restaurantId}", + "RequestDTORef": "", + "ResponseDTORef": "ComplaintResponse", + "RequiresAuth": true, + "RequiredRoles": [ + "Admin", + "RestaurantOwner" + ], + "PathParams": [ + { + "Name": "restaurantId", + "Type": "UUID" + } + ], + "QueryParams": [], + "StatusCodes": [ + { + "Code": 200, + "Description": "Restaurant complaints retrieved" + } + ], + "MiddlewareRefs": [], + "Description": "Get complaints for a restaurant" + }, + { + "HttpMethod": "PATCH", + "Route": "/{complaintId}/status", + "RequestDTORef": "", + "ResponseDTORef": "ComplaintResponse", + "RequiresAuth": true, + "RequiredRoles": [ + "Admin" + ], + "PathParams": [ + { + "Name": "complaintId", + "Type": "UUID" + } + ], + "QueryParams": [ + { + "Name": "status", + "Type": "string", + "Required": true + }, + { + "Name": "adminNotes", + "Type": "string", + "Required": false + } + ], + "StatusCodes": [ + { + "Code": 200, + "Description": "Status updated" + }, + { + "Code": 400, + "Description": "Invalid status transition" + }, + { + "Code": 404, + "Description": "Complaint not found" + } + ], + "MiddlewareRefs": [], + "Description": "Update complaint status (admin only)" + } + ] + } + } + ], + "edges": [ + { + "id": "42af4e61-c16b-45e0-8173-e9a703e4809f", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "506fea2d-e7ae-4c9f-9cb2-e1cbf0669696", + "targetNodeId": "366edf5d-3f24-4aa7-b0d7-ebacb3c781fe", + "kind": "USES", + "createdAt": "2026-06-07T15:50:04.746Z", + "updatedAt": "2026-06-07T15:50:04.746Z", + "properties": { + "IsAsync": false, + "Label": "enum reference" + } + }, + { + "id": "af96af08-8f97-4b8b-8a50-d5923e5e9000", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "a9c664ff-dc2c-4680-b685-0ea92dc4f5f8", + "targetNodeId": "c2c35f15-9e60-42d7-b928-56cc548da39d", + "kind": "CALLS", + "createdAt": "2026-06-07T15:48:30.592Z", + "updatedAt": "2026-06-07T15:48:30.592Z", + "properties": { + "IsAsync": false, + "Label": "find/create user" + } + }, + { + "id": "5b78cf69-8bd6-4e9b-a7f8-80f1effc2cb4", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "a9c664ff-dc2c-4680-b685-0ea92dc4f5f8", + "targetNodeId": "138616a2-04e0-4666-bae5-2a0d0abcade1", + "kind": "CALLS", + "createdAt": "2026-06-07T15:48:31.383Z", + "updatedAt": "2026-06-07T15:48:31.383Z", + "properties": { + "IsAsync": false, + "Label": "get user by email" + } + }, + { + "id": "6560be7c-fb7a-4626-8bac-06425d539e7e", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "138616a2-04e0-4666-bae5-2a0d0abcade1", + "targetNodeId": "c2c35f15-9e60-42d7-b928-56cc548da39d", + "kind": "CALLS", + "createdAt": "2026-06-07T15:48:31.407Z", + "updatedAt": "2026-06-07T15:48:31.407Z", + "properties": { + "IsAsync": false, + "Label": "query users" + } + }, + { + "id": "913ffdfb-6ff2-4fea-bb53-2f8b8783b88c", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "3ec99a80-f6e0-433e-b083-2a360238066e", + "targetNodeId": "9bc491d9-80f9-4f04-815e-e9e9dd3919ce", + "kind": "CALLS", + "createdAt": "2026-06-07T15:48:31.431Z", + "updatedAt": "2026-06-07T15:48:31.431Z", + "properties": { + "IsAsync": false, + "Label": "query restaurants" + } + }, + { + "id": "14c309e0-baaa-4b4d-a953-8e307e4b67c3", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "bb80edd5-3205-4b4e-94cb-d6c4b3216eea", + "targetNodeId": "2c194d94-65bc-46a3-b4c4-429d6d0727d4", + "kind": "CALLS", + "createdAt": "2026-06-07T15:48:31.450Z", + "updatedAt": "2026-06-07T15:48:31.450Z", + "properties": { + "IsAsync": false, + "Label": "query menu items" + } + }, + { + "id": "ace2b961-ce88-4427-a2c7-c8a088827afc", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "197648e1-e59b-495f-ab34-b72032be608a", + "targetNodeId": "ee38301d-4ecd-4c3c-8e29-5cd9d68a787f", + "kind": "CALLS", + "createdAt": "2026-06-07T15:48:31.464Z", + "updatedAt": "2026-06-07T15:48:31.464Z", + "properties": { + "IsAsync": false, + "Label": "manage orders" + } + }, + { + "id": "567e7a76-f22c-4a1c-8685-bc47e343acf8", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "197648e1-e59b-495f-ab34-b72032be608a", + "targetNodeId": "2c194d94-65bc-46a3-b4c4-429d6d0727d4", + "kind": "CALLS", + "createdAt": "2026-06-07T15:48:31.481Z", + "updatedAt": "2026-06-07T15:48:31.481Z", + "properties": { + "IsAsync": false, + "Label": "validate menu items" + } + }, + { + "id": "d1836a90-ddec-40cd-993d-13a8ba5f5d4d", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "08f77d07-e08a-45dc-83aa-bdb10d761abc", + "targetNodeId": "9193c1a2-ba3f-4516-87d5-579a2e0dbb20", + "kind": "CALLS", + "createdAt": "2026-06-07T15:48:31.500Z", + "updatedAt": "2026-06-07T15:48:31.500Z", + "properties": { + "IsAsync": false, + "Label": "manage reservations" + } + }, + { + "id": "894a6514-a4cc-4a6f-935b-d42819c4f4c6", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "9ca5296d-dc30-4c38-be62-fb77f228393c", + "targetNodeId": "b7d1eec7-0624-4fae-ad44-3358056e3093", + "kind": "CALLS", + "createdAt": "2026-06-07T15:48:31.523Z", + "updatedAt": "2026-06-07T15:48:31.523Z", + "properties": { + "IsAsync": false, + "Label": "manage reviews" + } + }, + { + "id": "ed4f1930-c82a-428e-ae51-3a82fd1ec949", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "9ca5296d-dc30-4c38-be62-fb77f228393c", + "targetNodeId": "9bc491d9-80f9-4f04-815e-e9e9dd3919ce", + "kind": "CALLS", + "createdAt": "2026-06-07T15:48:31.540Z", + "updatedAt": "2026-06-07T15:48:31.540Z", + "properties": { + "IsAsync": false, + "Label": "validate restaurant" + } + }, + { + "id": "22712687-b41f-4df7-af9e-191aaaf29d58", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "c2c35f15-9e60-42d7-b928-56cc548da39d", + "targetNodeId": "506fea2d-e7ae-4c9f-9cb2-e1cbf0669696", + "kind": "QUERIES", + "createdAt": "2026-06-07T15:48:48.159Z", + "updatedAt": "2026-06-07T15:48:48.159Z", + "properties": { + "IsAsync": false, + "Label": "read/write users" + } + }, + { + "id": "97dcf8b6-9100-49b5-b55f-229d85380dc7", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "c2c35f15-9e60-42d7-b928-56cc548da39d", + "targetNodeId": "506fea2d-e7ae-4c9f-9cb2-e1cbf0669696", + "kind": "WRITES", + "createdAt": "2026-06-07T15:48:48.279Z", + "updatedAt": "2026-06-07T15:48:48.279Z", + "properties": { + "IsAsync": false, + "Label": "write users" + } + }, + { + "id": "86f98225-9e42-4e8e-b27f-6b5ca5dcc3ea", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "9bc491d9-80f9-4f04-815e-e9e9dd3919ce", + "targetNodeId": "44618c13-2b8c-4ebf-a538-c01ee3d23732", + "kind": "QUERIES", + "createdAt": "2026-06-07T15:48:48.327Z", + "updatedAt": "2026-06-07T15:48:48.327Z", + "properties": { + "IsAsync": false, + "Label": "read/write restaurants" + } + }, + { + "id": "cad12ed6-96f8-4eea-9741-8f68ad9033eb", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "9bc491d9-80f9-4f04-815e-e9e9dd3919ce", + "targetNodeId": "44618c13-2b8c-4ebf-a538-c01ee3d23732", + "kind": "WRITES", + "createdAt": "2026-06-07T15:48:48.349Z", + "updatedAt": "2026-06-07T15:48:48.349Z", + "properties": { + "IsAsync": false, + "Label": "write restaurants" + } + }, + { + "id": "69713d38-b0b9-4933-8d29-8f94e479c02b", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "2c194d94-65bc-46a3-b4c4-429d6d0727d4", + "targetNodeId": "a6b1fa81-a0bf-4cc0-9643-b77cf0c8a532", + "kind": "QUERIES", + "createdAt": "2026-06-07T15:48:48.366Z", + "updatedAt": "2026-06-07T15:48:48.366Z", + "properties": { + "IsAsync": false, + "Label": "read/write menu items" + } + }, + { + "id": "d843ebd6-7fce-4f2e-9b16-d632869f219a", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "2c194d94-65bc-46a3-b4c4-429d6d0727d4", + "targetNodeId": "a6b1fa81-a0bf-4cc0-9643-b77cf0c8a532", + "kind": "WRITES", + "createdAt": "2026-06-07T15:48:48.389Z", + "updatedAt": "2026-06-07T15:48:48.389Z", + "properties": { + "IsAsync": false, + "Label": "write menu items" + } + }, + { + "id": "4fa786ef-7223-4d6d-bb72-f61e186eb27f", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "ee38301d-4ecd-4c3c-8e29-5cd9d68a787f", + "targetNodeId": "c377a033-3c4f-4ed2-b1e4-90bb109c745e", + "kind": "QUERIES", + "createdAt": "2026-06-07T15:48:48.408Z", + "updatedAt": "2026-06-07T15:48:48.408Z", + "properties": { + "IsAsync": false, + "Label": "read/write orders" + } + }, + { + "id": "da2a22a0-614f-4b48-8399-617a092b560e", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "ee38301d-4ecd-4c3c-8e29-5cd9d68a787f", + "targetNodeId": "c377a033-3c4f-4ed2-b1e4-90bb109c745e", + "kind": "WRITES", + "createdAt": "2026-06-07T15:48:48.428Z", + "updatedAt": "2026-06-07T15:48:48.428Z", + "properties": { + "IsAsync": false, + "Label": "write orders" + } + }, + { + "id": "1c1c3f99-665a-4b3d-9ce2-2b56067b6e0c", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "ee38301d-4ecd-4c3c-8e29-5cd9d68a787f", + "targetNodeId": "90fdb61a-921b-4b1a-83ef-a54976315f94", + "kind": "QUERIES", + "createdAt": "2026-06-07T15:48:48.448Z", + "updatedAt": "2026-06-07T15:48:48.448Z", + "properties": { + "IsAsync": false, + "Label": "read/write order items" + } + }, + { + "id": "aaaaef61-755a-46bd-af63-67c6a242910b", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "ee38301d-4ecd-4c3c-8e29-5cd9d68a787f", + "targetNodeId": "90fdb61a-921b-4b1a-83ef-a54976315f94", + "kind": "WRITES", + "createdAt": "2026-06-07T15:48:48.466Z", + "updatedAt": "2026-06-07T15:48:48.466Z", + "properties": { + "IsAsync": false, + "Label": "write order items" + } + }, + { + "id": "efe4cf9e-03f4-4575-9c8b-09ef23d88058", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "9193c1a2-ba3f-4516-87d5-579a2e0dbb20", + "targetNodeId": "8d92444d-7a4e-42d1-901d-d260b916c172", + "kind": "QUERIES", + "createdAt": "2026-06-07T15:49:15.018Z", + "updatedAt": "2026-06-07T15:49:15.018Z", + "properties": { + "IsAsync": false, + "Label": "read/write reservations" + } + }, + { + "id": "7263c5c0-2878-4cad-b541-13e07abe04c0", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "9193c1a2-ba3f-4516-87d5-579a2e0dbb20", + "targetNodeId": "8d92444d-7a4e-42d1-901d-d260b916c172", + "kind": "WRITES", + "createdAt": "2026-06-07T15:49:15.040Z", + "updatedAt": "2026-06-07T15:49:15.040Z", + "properties": { + "IsAsync": false, + "Label": "write reservations" + } + }, + { + "id": "2f51880d-22af-4eed-8bcd-b9be43c18cf9", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "b7d1eec7-0624-4fae-ad44-3358056e3093", + "targetNodeId": "7ea78369-5543-4873-9186-f26f37442a4c", + "kind": "QUERIES", + "createdAt": "2026-06-07T15:49:15.061Z", + "updatedAt": "2026-06-07T15:49:15.061Z", + "properties": { + "IsAsync": false, + "Label": "read/write reviews" + } + }, + { + "id": "59eb8354-2642-4a33-8578-af9bc75ca179", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "b7d1eec7-0624-4fae-ad44-3358056e3093", + "targetNodeId": "7ea78369-5543-4873-9186-f26f37442a4c", + "kind": "WRITES", + "createdAt": "2026-06-07T15:49:15.086Z", + "updatedAt": "2026-06-07T15:49:15.086Z", + "properties": { + "IsAsync": false, + "Label": "write reviews" + } + }, + { + "id": "4ea5f0a7-390c-411c-af58-246aae39d599", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "973fcea7-1599-47c6-adad-b9d37bd3a9d7", + "targetNodeId": "a9c664ff-dc2c-4680-b685-0ea92dc4f5f8", + "kind": "CALLS", + "createdAt": "2026-06-07T15:49:49.975Z", + "updatedAt": "2026-06-07T15:49:49.975Z", + "properties": { + "IsAsync": false, + "Label": "authenticate" + } + }, + { + "id": "4cc61951-68e6-4021-9798-fbed62c097f8", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "80d1ee01-a949-40e0-9c3f-d869af612cd0", + "targetNodeId": "3ec99a80-f6e0-433e-b083-2a360238066e", + "kind": "CALLS", + "createdAt": "2026-06-07T15:49:49.995Z", + "updatedAt": "2026-06-07T15:49:49.995Z", + "properties": { + "IsAsync": false, + "Label": "list/search restaurants" + } + }, + { + "id": "7abdf5f0-b34a-4544-8937-bed760c42375", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "6a89ff3c-c6c8-46ee-9019-a4069843d627", + "targetNodeId": "bb80edd5-3205-4b4e-94cb-d6c4b3216eea", + "kind": "CALLS", + "createdAt": "2026-06-07T15:49:50.014Z", + "updatedAt": "2026-06-07T15:49:50.014Z", + "properties": { + "IsAsync": false, + "Label": "manage menu" + } + }, + { + "id": "15ede38a-4ddb-4a03-87b2-0f60076a08c0", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "9635ae7d-ba0e-40b8-af87-dfea9b4d3868", + "targetNodeId": "197648e1-e59b-495f-ab34-b72032be608a", + "kind": "CALLS", + "createdAt": "2026-06-07T15:49:50.036Z", + "updatedAt": "2026-06-07T15:49:50.036Z", + "properties": { + "IsAsync": false, + "Label": "manage orders" + } + }, + { + "id": "2409ca82-76c4-4226-8503-0c96fe93a4a5", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "367c0ff6-9aa7-4538-a85e-ef4502ba2b6f", + "targetNodeId": "08f77d07-e08a-45dc-83aa-bdb10d761abc", + "kind": "CALLS", + "createdAt": "2026-06-07T15:49:50.058Z", + "updatedAt": "2026-06-07T15:49:50.058Z", + "properties": { + "IsAsync": false, + "Label": "manage reservations" + } + }, + { + "id": "4d3c0eaf-c77d-490a-b575-01b72eaa7ab5", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "2dbcde72-f38c-4584-8665-127a69cbde80", + "targetNodeId": "9ca5296d-dc30-4c38-be62-fb77f228393c", + "kind": "CALLS", + "createdAt": "2026-06-07T15:49:50.072Z", + "updatedAt": "2026-06-07T15:49:50.072Z", + "properties": { + "IsAsync": false, + "Label": "manage reviews" + } + }, + { + "id": "4ec48ff2-fbac-4778-bc88-d9958be1d42a", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "973fcea7-1599-47c6-adad-b9d37bd3a9d7", + "targetNodeId": "a819d7c7-81c1-4cb9-bcbc-f9875e14bbb4", + "kind": "USES", + "createdAt": "2026-06-07T15:49:50.119Z", + "updatedAt": "2026-06-07T15:49:50.119Z", + "properties": { + "IsAsync": false, + "Label": "request payload" + } + }, + { + "id": "606f3d7c-f54f-467d-81eb-390254f86afb", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "973fcea7-1599-47c6-adad-b9d37bd3a9d7", + "targetNodeId": "496eead2-d4d1-4525-9317-60d75cb2e97f", + "kind": "USES", + "createdAt": "2026-06-07T15:49:50.165Z", + "updatedAt": "2026-06-07T15:49:50.165Z", + "properties": { + "IsAsync": false, + "Label": "request payload" + } + }, + { + "id": "ae5ea413-51a0-4a1c-bb7b-de571f6222d5", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "973fcea7-1599-47c6-adad-b9d37bd3a9d7", + "targetNodeId": "ef93a957-3b99-41b8-8979-b081a7cbb857", + "kind": "USES", + "createdAt": "2026-06-07T15:49:50.183Z", + "updatedAt": "2026-06-07T15:49:50.183Z", + "properties": { + "IsAsync": false, + "Label": "response payload" + } + }, + { + "id": "54a2f263-6903-4ee7-b19e-b80bb74b3518", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "9635ae7d-ba0e-40b8-af87-dfea9b4d3868", + "targetNodeId": "a06cc4ee-71ba-409f-85ef-837f699bc84f", + "kind": "USES", + "createdAt": "2026-06-07T15:49:50.197Z", + "updatedAt": "2026-06-07T15:49:50.197Z", + "properties": { + "IsAsync": false, + "Label": "request payload" + } + }, + { + "id": "04d6cdd1-746b-41a3-a0ab-c52d1c6ad8ae", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "9635ae7d-ba0e-40b8-af87-dfea9b4d3868", + "targetNodeId": "06cbfd54-430a-4e3f-a8c8-15a73c55cc71", + "kind": "USES", + "createdAt": "2026-06-07T15:50:04.612Z", + "updatedAt": "2026-06-07T15:50:04.612Z", + "properties": { + "IsAsync": false, + "Label": "response payload" + } + }, + { + "id": "043567ea-7956-4572-9d1c-f69e82a98d4c", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "367c0ff6-9aa7-4538-a85e-ef4502ba2b6f", + "targetNodeId": "79f7f335-41d8-49f4-84a5-117b67e5b92d", + "kind": "USES", + "createdAt": "2026-06-07T15:50:04.629Z", + "updatedAt": "2026-06-07T15:50:04.629Z", + "properties": { + "IsAsync": false, + "Label": "request payload" + } + }, + { + "id": "0b7011f9-1c5f-4ed8-900a-bbac305eaf94", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "367c0ff6-9aa7-4538-a85e-ef4502ba2b6f", + "targetNodeId": "a454c7c0-d20a-4399-a9b2-039929a70cfa", + "kind": "USES", + "createdAt": "2026-06-07T15:50:04.650Z", + "updatedAt": "2026-06-07T15:50:04.650Z", + "properties": { + "IsAsync": false, + "Label": "response payload" + } + }, + { + "id": "133aee10-c326-4f50-a263-4f30db3e9033", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "2dbcde72-f38c-4584-8665-127a69cbde80", + "targetNodeId": "a241de15-d05e-435c-8f95-f9e94462b763", + "kind": "USES", + "createdAt": "2026-06-07T15:50:04.671Z", + "updatedAt": "2026-06-07T15:50:04.671Z", + "properties": { + "IsAsync": false, + "Label": "request payload" + } + }, + { + "id": "af52b89b-b923-4656-bda0-674516bde633", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "2dbcde72-f38c-4584-8665-127a69cbde80", + "targetNodeId": "00f0232d-5c39-403b-9c48-bb193ce85a64", + "kind": "USES", + "createdAt": "2026-06-07T15:50:04.684Z", + "updatedAt": "2026-06-07T15:50:04.684Z", + "properties": { + "IsAsync": false, + "Label": "response payload" + } + }, + { + "id": "3d950574-244b-41d5-8f95-d4414fdbbb48", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "80d1ee01-a949-40e0-9c3f-d869af612cd0", + "targetNodeId": "3175404e-6478-420a-8ea3-7e7d95e44ca5", + "kind": "USES", + "createdAt": "2026-06-07T15:50:04.701Z", + "updatedAt": "2026-06-07T15:50:04.701Z", + "properties": { + "IsAsync": false, + "Label": "response payload" + } + }, + { + "id": "f687bf35-3a91-41c8-a54a-dea7faf743c9", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "6a89ff3c-c6c8-46ee-9019-a4069843d627", + "targetNodeId": "0a88569c-ede9-4851-972f-33758ada0307", + "kind": "USES", + "createdAt": "2026-06-07T15:50:04.716Z", + "updatedAt": "2026-06-07T15:50:04.716Z", + "properties": { + "IsAsync": false, + "Label": "response payload" + } + }, + { + "id": "a2a50618-ae7f-4f11-8d32-78b49c74b667", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "c377a033-3c4f-4ed2-b1e4-90bb109c745e", + "targetNodeId": "c14b1bde-d739-46ce-8c17-809bdcd38c27", + "kind": "USES", + "createdAt": "2026-06-07T15:50:04.761Z", + "updatedAt": "2026-06-07T15:50:04.761Z", + "properties": { + "IsAsync": false, + "Label": "enum reference" + } + }, + { + "id": "44def2b5-fad9-40ae-bc64-bcf799f56d29", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "8d92444d-7a4e-42d1-901d-d260b916c172", + "targetNodeId": "c7df2f28-2db7-44b5-a95f-1f5f85269886", + "kind": "USES", + "createdAt": "2026-06-07T15:50:04.777Z", + "updatedAt": "2026-06-07T15:50:04.777Z", + "properties": { + "IsAsync": false, + "Label": "enum reference" + } + }, + { + "id": "db43b6d1-aa91-490d-98a8-14a0342abb09", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "44618c13-2b8c-4ebf-a538-c01ee3d23732", + "targetNodeId": "f7b411e0-37bb-4be1-b0a6-455a52a2c84e", + "kind": "USES", + "createdAt": "2026-06-07T15:50:21.177Z", + "updatedAt": "2026-06-07T15:50:21.177Z", + "properties": { + "IsAsync": false, + "Label": "enum reference" + } + }, + { + "id": "d56a3fc9-d03c-4008-93a6-d70e732d0165", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "138616a2-04e0-4666-bae5-2a0d0abcade1", + "targetNodeId": "7d0a06b5-204f-4d99-8415-8bbe5b6826cd", + "kind": "THROWS", + "createdAt": "2026-06-07T15:50:21.267Z", + "updatedAt": "2026-06-07T15:50:21.267Z", + "properties": { + "IsAsync": false, + "Label": "not found" + } + }, + { + "id": "c70a2a8b-f121-4c1d-9032-697730b0b111", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "a9c664ff-dc2c-4680-b685-0ea92dc4f5f8", + "targetNodeId": "5903ce82-a9cd-43a4-bcac-d6f757454e22", + "kind": "THROWS", + "createdAt": "2026-06-07T15:50:21.305Z", + "updatedAt": "2026-06-07T15:50:21.305Z", + "properties": { + "IsAsync": false, + "Label": "validation error" + } + }, + { + "id": "4787b964-91a2-40b9-8e10-9b43c68ae592", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "a9c664ff-dc2c-4680-b685-0ea92dc4f5f8", + "targetNodeId": "577b9eb8-2870-45e1-b4d9-834b7a51cdbd", + "kind": "THROWS", + "createdAt": "2026-06-07T15:50:21.323Z", + "updatedAt": "2026-06-07T15:50:21.323Z", + "properties": { + "IsAsync": false, + "Label": "unauthorized" + } + }, + { + "id": "b07b6f3b-edb8-48f2-bc52-dfe24a9cdf42", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "a9c664ff-dc2c-4680-b685-0ea92dc4f5f8", + "targetNodeId": "f979cdc0-772b-43d7-8619-cdbdc83b08e1", + "kind": "THROWS", + "createdAt": "2026-06-07T15:50:21.336Z", + "updatedAt": "2026-06-07T15:50:21.336Z", + "properties": { + "IsAsync": false, + "Label": "conflict" + } + }, + { + "id": "ff3a3e32-ba46-4776-b264-5e191323d941", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "3ec99a80-f6e0-433e-b083-2a360238066e", + "targetNodeId": "0ac8863d-db5c-4294-a938-2e24d60047cb", + "kind": "CACHES_IN", + "createdAt": "2026-06-07T15:50:21.382Z", + "updatedAt": "2026-06-07T15:50:21.382Z", + "properties": { + "IsAsync": false, + "Label": "cache restaurants" + } + }, + { + "id": "7123f61c-db46-41f4-9023-2f29aab425ee", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "bb80edd5-3205-4b4e-94cb-d6c4b3216eea", + "targetNodeId": "0ac8863d-db5c-4294-a938-2e24d60047cb", + "kind": "CACHES_IN", + "createdAt": "2026-06-07T15:50:21.414Z", + "updatedAt": "2026-06-07T15:50:21.414Z", + "properties": { + "IsAsync": false, + "Label": "cache menu" + } + }, + { + "id": "8987f87e-3aa1-4ebb-ade5-5fac0bbd8f93", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "9635ae7d-ba0e-40b8-af87-dfea9b4d3868", + "targetNodeId": "c98476b6-ee6f-43ac-bf49-7fd97943fc7e", + "kind": "USES", + "createdAt": "2026-06-07T15:50:37.597Z", + "updatedAt": "2026-06-07T15:50:37.597Z", + "properties": { + "IsAsync": false, + "Label": "nested request DTO" + } + }, + { + "id": "b6a2576b-cccc-4611-8ad9-9c0d1925b64b", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "9635ae7d-ba0e-40b8-af87-dfea9b4d3868", + "targetNodeId": "05660a68-240d-4bf5-904e-3bfe52d414d7", + "kind": "USES", + "createdAt": "2026-06-07T15:50:37.619Z", + "updatedAt": "2026-06-07T15:50:37.619Z", + "properties": { + "IsAsync": false, + "Label": "nested response DTO" + } + }, + { + "id": "edfb77c4-53a4-48f1-93a5-9ce7218028ea", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "a9c664ff-dc2c-4680-b685-0ea92dc4f5f8", + "targetNodeId": "ecf4ac9c-91f4-4fa8-abf7-f5585aef35b9", + "kind": "READS_CONFIG", + "createdAt": "2026-06-07T15:50:37.634Z", + "updatedAt": "2026-06-07T15:50:37.634Z", + "properties": { + "IsAsync": false, + "Label": "db connection" + } + }, + { + "id": "ea1261d9-32d6-4f2a-946c-f9799c6532b7", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "a9c664ff-dc2c-4680-b685-0ea92dc4f5f8", + "targetNodeId": "e1c17afb-4ba5-4fbc-917b-1e8fa6e8ea40", + "kind": "READS_CONFIG", + "createdAt": "2026-06-07T15:50:37.712Z", + "updatedAt": "2026-06-07T15:50:37.712Z", + "properties": { + "IsAsync": false, + "Label": "jwt secret" + } + }, + { + "id": "6ccfacc5-e2ac-45aa-b2aa-5106b9933a67", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "3ec99a80-f6e0-433e-b083-2a360238066e", + "targetNodeId": "2364ad66-f7bc-4fd2-aa2b-ad60c26dcb7d", + "kind": "READS_CONFIG", + "createdAt": "2026-06-07T15:50:37.725Z", + "updatedAt": "2026-06-07T15:50:37.725Z", + "properties": { + "IsAsync": false, + "Label": "redis url" + } + }, + { + "id": "aaa52860-91c2-4df9-8f6b-54a87d9b4f45", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "43b706e7-70ac-4915-8500-402b7271d2aa", + "targetNodeId": "973fcea7-1599-47c6-adad-b9d37bd3a9d7", + "kind": "ROUTES_TO", + "createdAt": "2026-06-07T15:50:37.763Z", + "updatedAt": "2026-06-07T15:50:37.763Z", + "properties": { + "IsAsync": false, + "Label": "auth middleware" + } + }, + { + "id": "3730ac8c-a59a-4fb0-9829-ceb52da8c86b", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "43b706e7-70ac-4915-8500-402b7271d2aa", + "targetNodeId": "80d1ee01-a949-40e0-9c3f-d869af612cd0", + "kind": "ROUTES_TO", + "createdAt": "2026-06-07T15:50:37.808Z", + "updatedAt": "2026-06-07T15:50:37.808Z", + "properties": { + "IsAsync": false, + "Label": "auth middleware" + } + }, + { + "id": "7a4ace6e-5531-4c4e-9ac5-860b8981bce2", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "43b706e7-70ac-4915-8500-402b7271d2aa", + "targetNodeId": "9635ae7d-ba0e-40b8-af87-dfea9b4d3868", + "kind": "ROUTES_TO", + "createdAt": "2026-06-07T15:50:37.826Z", + "updatedAt": "2026-06-07T15:50:37.826Z", + "properties": { + "IsAsync": false, + "Label": "auth middleware" + } + }, + { + "id": "f19f5aa8-0c6f-4272-8b00-7dddfc9d4a1b", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "43b706e7-70ac-4915-8500-402b7271d2aa", + "targetNodeId": "367c0ff6-9aa7-4538-a85e-ef4502ba2b6f", + "kind": "ROUTES_TO", + "createdAt": "2026-06-07T15:50:37.843Z", + "updatedAt": "2026-06-07T15:50:37.843Z", + "properties": { + "IsAsync": false, + "Label": "auth middleware" + } + }, + { + "id": "91ea5d44-7962-4e4b-aca1-ea1865811dbc", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "43b706e7-70ac-4915-8500-402b7271d2aa", + "targetNodeId": "2dbcde72-f38c-4584-8665-127a69cbde80", + "kind": "ROUTES_TO", + "createdAt": "2026-06-07T15:50:37.858Z", + "updatedAt": "2026-06-07T15:50:37.858Z", + "properties": { + "IsAsync": false, + "Label": "auth middleware" + } + }, + { + "id": "c4cd0494-78dd-465e-aad5-2c33d40a908d", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "c2e6b671-0fc9-488d-8984-93fb886a75f5", + "targetNodeId": "973fcea7-1599-47c6-adad-b9d37bd3a9d7", + "kind": "ROUTES_TO", + "createdAt": "2026-06-07T15:50:44.657Z", + "updatedAt": "2026-06-07T15:50:44.657Z", + "properties": { + "IsAsync": false, + "Label": "error handler" + } + }, + { + "id": "2e76f6e1-7dc8-4f5f-9dfb-aab6ee745c72", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "ace78904-8e85-42c4-ad84-e38921f30a9c", + "targetNodeId": "973fcea7-1599-47c6-adad-b9d37bd3a9d7", + "kind": "ROUTES_TO", + "createdAt": "2026-06-07T15:50:44.681Z", + "updatedAt": "2026-06-07T15:50:44.681Z", + "properties": { + "IsAsync": false, + "Label": "rate limit" + } + }, + { + "id": "d908b5f9-1f38-48f6-b431-8a3baf6fe15d", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "43b706e7-70ac-4915-8500-402b7271d2aa", + "targetNodeId": "6a89ff3c-c6c8-46ee-9019-a4069843d627", + "kind": "ROUTES_TO", + "createdAt": "2026-06-07T15:50:44.698Z", + "updatedAt": "2026-06-07T15:50:44.698Z", + "properties": { + "IsAsync": false, + "Label": "auth middleware" + } + }, + { + "id": "5ef0e40b-39dd-47e4-ad97-fb0c40398ade", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "c332284b-e9a6-4d9f-8b2f-e443bd7f6db3", + "targetNodeId": "0923c560-3e38-4bb9-a380-c408eb8623d6", + "kind": "USES", + "createdAt": "2026-06-12T15:34:36.101Z", + "updatedAt": "2026-06-12T15:34:36.101Z", + "properties": { + "IsAsync": false, + "Label": "complaint status" + } + }, + { + "id": "1d831908-6bbc-409e-ae98-a7f25aaeb987", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "5bc8d6c0-afd5-40e9-bed4-a50367e78654", + "targetNodeId": "0923c560-3e38-4bb9-a380-c408eb8623d6", + "kind": "USES", + "createdAt": "2026-06-12T15:34:36.112Z", + "updatedAt": "2026-06-12T15:34:36.112Z", + "properties": { + "IsAsync": false, + "Label": "status enum" + } + }, + { + "id": "b963dc1b-1f6f-45aa-96dd-fcecccbeed57", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "33a6b81b-08ba-4780-95b6-773997e4bd0f", + "targetNodeId": "c5788d15-95cb-4bf9-a47e-f5ab389d6dec", + "kind": "USES", + "createdAt": "2026-06-12T15:34:36.122Z", + "updatedAt": "2026-06-12T15:34:36.122Z", + "properties": { + "IsAsync": false, + "Label": "request payload" + } + }, + { + "id": "28762586-9418-4ab7-88e0-b4aead8f09c8", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "33a6b81b-08ba-4780-95b6-773997e4bd0f", + "targetNodeId": "5bc8d6c0-afd5-40e9-bed4-a50367e78654", + "kind": "USES", + "createdAt": "2026-06-12T15:34:36.135Z", + "updatedAt": "2026-06-12T15:34:36.135Z", + "properties": { + "IsAsync": false, + "Label": "response payload" + } + }, + { + "id": "9438a4fd-e6fb-449c-b81c-d9228de1bed9", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "33a6b81b-08ba-4780-95b6-773997e4bd0f", + "targetNodeId": "1bb2c6d2-37f3-4e98-ad1e-7e0a61aceb9e", + "kind": "CALLS", + "createdAt": "2026-06-12T15:34:36.143Z", + "updatedAt": "2026-06-12T15:34:36.143Z", + "properties": { + "IsAsync": false, + "Label": "delegate to service" + } + }, + { + "id": "ca401507-0ac9-4505-9d66-d3ab802dfc7a", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "1bb2c6d2-37f3-4e98-ad1e-7e0a61aceb9e", + "targetNodeId": "8166cfaf-8363-4110-b2a6-7e7fc3437bb9", + "kind": "CALLS", + "createdAt": "2026-06-12T15:34:36.151Z", + "updatedAt": "2026-06-12T15:34:36.151Z", + "properties": { + "IsAsync": false, + "Label": "data access" + } + }, + { + "id": "8c363d17-7734-4cdd-baa1-6df6ed041004", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "8166cfaf-8363-4110-b2a6-7e7fc3437bb9", + "targetNodeId": "c332284b-e9a6-4d9f-8b2f-e443bd7f6db3", + "kind": "QUERIES", + "createdAt": "2026-06-12T15:34:36.164Z", + "updatedAt": "2026-06-12T15:34:36.164Z", + "properties": { + "IsAsync": false, + "Label": "read complaints" + } + }, + { + "id": "89980d68-49cb-4469-b642-b208f5b31e19", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "8166cfaf-8363-4110-b2a6-7e7fc3437bb9", + "targetNodeId": "c332284b-e9a6-4d9f-8b2f-e443bd7f6db3", + "kind": "WRITES", + "createdAt": "2026-06-12T15:34:36.174Z", + "updatedAt": "2026-06-12T15:34:36.174Z", + "properties": { + "IsAsync": false, + "Label": "insert/update complaints" + } + }, + { + "id": "02cef8d4-c9b0-4777-a8b5-01a89ce03a91", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "1bb2c6d2-37f3-4e98-ad1e-7e0a61aceb9e", + "targetNodeId": "5903ce82-a9cd-43a4-bcac-d6f757454e22", + "kind": "THROWS", + "createdAt": "2026-06-12T15:34:36.181Z", + "updatedAt": "2026-06-12T15:34:36.181Z", + "properties": { + "IsAsync": false, + "Label": "invalid input" + } + }, + { + "id": "4e39742c-c2b4-4573-8f4a-278f417c739f", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "1bb2c6d2-37f3-4e98-ad1e-7e0a61aceb9e", + "targetNodeId": "7d0a06b5-204f-4d99-8415-8bbe5b6826cd", + "kind": "THROWS", + "createdAt": "2026-06-12T15:34:36.190Z", + "updatedAt": "2026-06-12T15:34:36.190Z", + "properties": { + "IsAsync": false, + "Label": "entity not found" + } + }, + { + "id": "b253b760-ea84-4cff-a406-a5ae402a1b49", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "1bb2c6d2-37f3-4e98-ad1e-7e0a61aceb9e", + "targetNodeId": "f979cdc0-772b-43d7-8619-cdbdc83b08e1", + "kind": "THROWS", + "createdAt": "2026-06-12T15:34:36.200Z", + "updatedAt": "2026-06-12T15:34:36.200Z", + "properties": { + "IsAsync": false, + "Label": "duplicate complaint" + } + }, + { + "id": "3696b97b-d9db-4447-b5b9-10fae1131d8d", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "43b706e7-70ac-4915-8500-402b7271d2aa", + "targetNodeId": "33a6b81b-08ba-4780-95b6-773997e4bd0f", + "kind": "ROUTES_TO", + "createdAt": "2026-06-12T15:34:36.208Z", + "updatedAt": "2026-06-12T15:34:36.208Z", + "properties": { + "IsAsync": false, + "Label": "auth required" + } + }, + { + "id": "3a394b8a-7d6d-44bb-b37e-a681a6ae0aa8", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "1bb2c6d2-37f3-4e98-ad1e-7e0a61aceb9e", + "targetNodeId": "c2c35f15-9e60-42d7-b928-56cc548da39d", + "kind": "CALLS", + "createdAt": "2026-06-12T15:34:45.023Z", + "updatedAt": "2026-06-12T15:34:45.023Z", + "properties": { + "IsAsync": false, + "Label": "validate user" + } + }, + { + "id": "5489c2e8-5ed5-4308-aaf4-436c7f3cdca5", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "1bb2c6d2-37f3-4e98-ad1e-7e0a61aceb9e", + "targetNodeId": "9bc491d9-80f9-4f04-815e-e9e9dd3919ce", + "kind": "CALLS", + "createdAt": "2026-06-12T15:34:45.031Z", + "updatedAt": "2026-06-12T15:34:45.031Z", + "properties": { + "IsAsync": false, + "Label": "validate restaurant" + } + }, + { + "id": "00ea9c29-409c-41de-baa8-cf518b01132d", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "1bb2c6d2-37f3-4e98-ad1e-7e0a61aceb9e", + "targetNodeId": "ee38301d-4ecd-4c3c-8e29-5cd9d68a787f", + "kind": "CALLS", + "createdAt": "2026-06-12T15:34:45.041Z", + "updatedAt": "2026-06-12T15:34:45.041Z", + "properties": { + "IsAsync": false, + "Label": "validate order" + } + }, + { + "id": "fa9e9fcd-5c7a-412b-88c9-5708babd58c8", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "1bb2c6d2-37f3-4e98-ad1e-7e0a61aceb9e", + "targetNodeId": "9193c1a2-ba3f-4516-87d5-579a2e0dbb20", + "kind": "CALLS", + "createdAt": "2026-06-12T15:34:45.049Z", + "updatedAt": "2026-06-12T15:34:45.049Z", + "properties": { + "IsAsync": false, + "Label": "validate reservation" + } + }, + { + "id": "f7812682-d33f-40f5-8445-43c5bee2237e", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "c2e6b671-0fc9-488d-8984-93fb886a75f5", + "targetNodeId": "33a6b81b-08ba-4780-95b6-773997e4bd0f", + "kind": "ROUTES_TO", + "createdAt": "2026-06-12T15:34:45.058Z", + "updatedAt": "2026-06-12T15:34:45.058Z", + "properties": { + "IsAsync": false, + "Label": "error handling" + } + }, + { + "id": "adc5060f-3fc1-42b7-b34f-a6bcfbdfb127", + "projectId": "9588cd9f-bddd-4d98-ae2f-2ab6dec4955c", + "sourceNodeId": "ace78904-8e85-42c4-ad84-e38921f30a9c", + "targetNodeId": "33a6b81b-08ba-4780-95b6-773997e4bd0f", + "kind": "ROUTES_TO", + "createdAt": "2026-06-12T15:34:45.066Z", + "updatedAt": "2026-06-12T15:34:45.066Z", + "properties": { + "IsAsync": false, + "Label": "rate limit" + } + } + ] +} diff --git a/apps/server/src/codegen/api-doc.spec.ts b/apps/server/src/codegen/api-doc.spec.ts new file mode 100644 index 0000000..d09c27d --- /dev/null +++ b/apps/server/src/codegen/api-doc.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import { CodegenService, applyDescribeOperation } from "./codegen.service"; +import { projectOpenApi } from "./openapi.emitter"; +import { buildCodeGraph } from "./ir"; +import type { StoredNode } from "../nodes/nodes.repository"; + +/* ──────────────────────────────────────────────────────────────────────── + * api-doc.spec.ts — CodegenService.apiDoc (baseline path). + * + * Mirrors the simpleSketchModel test shape: a small Controller fixture fed + * through stubbed repositories. The "baseline" stage is fully deterministic + * (no AI, no persistence touched), so it asserts that apiDoc yields the + * projected OpenAPI doc with paths and source "deterministic" regardless of + * whether AI is configured. The AI-enriched path lands in Task 4. + * ──────────────────────────────────────────────────────────────────────── */ + +let seq = 0; +const uuid = () => `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`; +function node(type: StoredNode["type"], properties: Record): StoredNode { + return { + id: uuid(), + type, + projectId: "p", + positionX: 0, + positionY: 0, + homeTabId: "t", + createdAt: "2026-06-29T00:00:00.000Z", + updatedAt: "2026-06-29T00:00:00.000Z", + version: 1, + properties, + }; +} + +function usersController(): StoredNode { + return node("Controller", { + ControllerName: "UsersController", + Description: "User ops", + BaseRoute: "/users", + Endpoints: [ + { HttpMethod: "POST", Route: "/", RequestDTORef: "CreateUserDto", ResponseDTORef: "UserDto", RequiresAuth: true, RequiredRoles: [], PathParams: [], QueryParams: [], StatusCodes: [{ Code: 201, Description: "Created" }] }, + { HttpMethod: "GET", Route: "/:id", ResponseDTORef: "UserDto", RequiresAuth: false, PathParams: [{ Name: "id", DataType: "string" }], QueryParams: [], StatusCodes: [] }, + ], + }); +} + +function makeService(nodes: StoredNode[]): CodegenService { + const projects = { exists: async () => true } as never; + const nodesRepo = { list: async () => nodes } as never; + const edgesRepo = { list: async () => [] } as never; + const surgicalFills = { getAllForProject: async () => [] } as never; + return new CodegenService(projects, nodesRepo, edgesRepo, surgicalFills); +} + +const PROJECT_ID = "00000000-0000-4000-8000-0000000000ff"; + +describe("CodegenService.apiDoc — baseline (no AI)", () => { + it("returns the deterministic OpenAPI doc with paths and source 'deterministic'", async () => { + const service = makeService([usersController()]); + const result = await service.apiDoc(PROJECT_ID, "baseline"); + + expect(result.source).toBe("deterministic"); + expect(typeof result.aiConfigured).toBe("boolean"); + expect(result.doc.openapi).toMatch(/^3\.1/); + expect(Object.keys(result.doc.paths).length).toBeGreaterThan(0); + expect(result.doc.paths["/users"]?.post).toBeTruthy(); + expect(result.doc.paths["/users/{id}"]?.get).toBeTruthy(); + }); +}); + +/* ──────────────────────────────────────────────────────────────────────── + * AI Documentize grounding (Task 4). The LLM itself is non-deterministic, so + * instead of driving a live agent we unit-test the grounded apply-helper the + * agent's tool calls funnel through. The guarantee under test: the helper only + * mutates EXISTING operations; an unknown operationId is a no-op that never + * adds a path or operation (the agent can annotate but can never invent API). + * ──────────────────────────────────────────────────────────────────────── */ +describe("aiDocumentizeOpenApi grounding — applyDescribeOperation", () => { + it("sets summary/description only when the operationId exists; unknown ids are no-ops", () => { + const doc = projectOpenApi(buildCodeGraph([usersController()], [])); + const post = doc.paths["/users"]!.post as { operationId: string; summary?: string; description?: string }; + const existingId = post.operationId; + const pathsBefore = JSON.stringify(doc.paths); + + const hit = applyDescribeOperation(doc, { + operationId: existingId, + summary: "Create a user", + description: "Creates a new user account and returns it.", + }); + expect(hit.ok).toBe(true); + const after = doc.paths["/users"]!.post as { summary?: string; description?: string }; + expect(after.summary).toBe("Create a user"); + expect(after.description).toBe("Creates a new user account and returns it."); + + const miss = applyDescribeOperation(doc, { operationId: "no_such_operation", summary: "ghost" }); + expect(miss.ok).toBe(false); + // The doc keeps exactly the same paths/operations — nothing invented. + expect(JSON.stringify(doc.paths)).not.toContain("ghost"); + expect(Object.keys(doc.paths)).toEqual(Object.keys(JSON.parse(pathsBefore))); + }); +}); diff --git a/apps/server/src/codegen/cardinality.ts b/apps/server/src/codegen/cardinality.ts new file mode 100644 index 0000000..1decf12 --- /dev/null +++ b/apps/server/src/codegen/cardinality.ts @@ -0,0 +1,39 @@ +/* ──────────────────────────────────────────────────────────────────────── + * cardinality.ts — SINGLE SOURCE for COLLECTION CARDINALITY. + * + * Does an operation (endpoint / service method) return singular or collection? + * This decision is needed in MULTIPLE emitters (controller + service); if they rely + * on different heuristics/word sets, signatures MISMATCH (controller XDto[], service + * XDto -> compile error, ListProducts/ListOrders bug in surgical-output). So both + * word set and derivation live in ONE place; emitters only read from here. + * + * Priority (both emitters APPLY): + * 1) Declared field (Endpoint.ReturnsCollection / ServiceMethod.ReturnsCollection) + * — when set WINS (true or false). "Declared > inferred." + * 2) Type already array (ReturnType "XDto[]" / "Array<...>"). + * 3) Name/route list-semantics fallback (list/all/search + findAll/findMany). + * + * PURE + DETERMINISTIC: input-dependent, side-effect free, EXACT word match. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Words that alone carry collection semantics (EXACT match — not substring; + * "listen"/"getAllowance" won't false-positive). */ +const COLLECTION_WORDS: ReadonlySet = new Set(["list", "all", "search"]); + +/** Words collection-only in JOINED form ("findAll" -> "findall"). */ +const COLLECTION_JOINED: ReadonlySet = new Set(["findall", "findmany"]); + +/** Do token array (splitWords output) carry collection semantics? + * controller (route segment) and service (method name) call THIS same function. */ +export function tokensHaveCollectionSemantics(tokens: readonly string[]): boolean { + const lower = tokens.map((t) => t.toLowerCase()); + if (COLLECTION_JOINED.has(lower.join(""))) return true; + return lower.some((w) => COLLECTION_WORDS.has(w)); +} + +/** Is a TS type string already a collection? ("X[]" suffix or "Array<...>"). + * Prevents double-wrapping declared/inferred collection ("XDto[]" -> "[][]" NOT). */ +export function isArrayType(t: string): boolean { + const s = t.trim(); + return s.endsWith("[]") || /^Array\s* files.filter((f) => f.language === "typescript"); + +describe("codegen assembly seam (realistic graph)", () => { + const files = assembleRealisticFixture(); + + it("assemble generates realistic graph (feature files + migrations)", () => { + expect(files.length).toBeGreaterThan(50); + // Expected features generated (restaurant/order/... repositories). + const repos = files.filter((f) => f.path.endsWith(".repository.ts")); + expect(repos.length).toBeGreaterThanOrEqual(5); + }); + + /* ── PK SEAM: findById key ALIGNED with entity property ────────────── + * Bug: graph gives PK as "Id" (capital); entity property becomes "id" via tsPropName; + * if repository uses raw "Id", findById queries nonexistent column (cast hides, + * runtime fails). After fix NO repo should use capital-first key + each + * findById key must exactly match its entity PK property. */ + it("no repository findById uses capital-first PK key ({ Id: id } regression)", () => { + for (const f of tsFiles(files)) { + // where key starting with capital like findById { Id: ... } = casing seam bug. + expect(f.content, `${f.path} carries capital-first PK where key`).not.toMatch( + /where:\s*\{\s*[A-Z]/, + ); + } + }); + + it("each repository findById key exactly matches mapped entity PK property name", () => { + // Collect entity PK property names (class -> pk): line after @PrimaryGeneratedColumn. + const pkByEntity = new Map(); + for (const f of tsFiles(files)) { + if (!f.path.includes("/entities/")) continue; + const cls = /export class (\w+)/.exec(f.content)?.[1]; + const pkLine = /@PrimaryGeneratedColumn\([^)]*\)\s*\n\s*(\w+)/.exec(f.content)?.[1]; + if (cls && pkLine) pkByEntity.set(cls, pkLine); + } + expect(pkByEntity.size).toBeGreaterThan(0); + + for (const f of tsFiles(files)) { + if (!f.path.endsWith(".repository.ts")) continue; + const entity = /Repository<(\w+)>/.exec(f.content)?.[1]; + const key = /where:\s*\{\s*(\w+): id\b/.exec(f.content)?.[1]; + if (!entity || !key || entity === "any") continue; // skip missing-entity edge case + const expected = pkByEntity.get(entity); + if (!expected) continue; // synthetic/unmapped entity — skip in this test + expect(key, `${f.path}: findById key '${key}' != entity '${entity}' PK '${expected}'`).toBe( + expected, + ); + } + }); + + /* ── CARDINALITY SEAM: controller collection return <-> service collection return + * Bug: controller route'tan "koleksiyon" tahmin edip DTO[] basar ama service tekil + * stays -> after fill `return result` (array) won't compile. After fix collection + * operations return DTO[] on both ends. Fixture has RestaurantService.GetAll/ + * Search as collection (graph ReturnType:"array"); both should be RestaurantResponse[]. */ + it("collection service methods return DTO[] (RestaurantService GetAll/Search)", () => { + const svc = files.find((f) => f.path.endsWith("restaurant/restaurant.service.ts")); + expect(svc).toBeDefined(); + // NOTE: GetById/Create/Update legitimately return SINGLE RestaurantResponse — + // single return EXISTENCE is not a bug. Seam bug was COLLECTION operation (GetAll/ + // Search) staying single; so we only verify they return DTO[]. + expect(svc!.content).toMatch(/async GetAll\([^)]*\): Promise/); + expect(svc!.content).toMatch(/async Search\([^)]*\): Promise/); + }); + + it("controller collection endpoint and service SAME DTO[] cardinality (seam aligned)", () => { + const ctrl = files.find((f) => f.path.endsWith("restaurant/restaurant.controller.ts")); + const svc = files.find((f) => f.path.endsWith("restaurant/restaurant.service.ts")); + expect(ctrl).toBeDefined(); + expect(svc).toBeDefined(); + // Controller carries at least one collection return... + expect(ctrl!.content).toMatch(/Promise/); + // ...and service also returns collection -> both ends COMPATIBLE (no single/array mismatch). + expect(svc!.content).toMatch(/Promise/); + }); + + /* ── ENUM DIKISI: entity (varchar) ↔ migration (VARCHAR + CHECK) ────────── + * #56: eskiden entity @Column({type:"enum"}) ama migration TEXT -> tutarsiz. Karar + * varchar+CHECK: hicbir entity native enum kolonu uretmez; migration enum kolonlarini + * VARCHAR + CHECK ile kisitlar (CREATE TYPE yok). Fixture'da enum kolonlari var. */ + it("hicbir entity native enum kolonu (type:\"enum\") uretmez (#56 regresyonu)", () => { + for (const f of tsFiles(files)) { + if (!f.path.includes("/entities/")) continue; + expect(f.content, `${f.path} carries native enum column`).not.toContain('type: "enum"'); + } + }); + + it("migration enum kolonlarini VARCHAR + CHECK ile kisitlar (CREATE TYPE yok)", () => { + const allSql = files.filter((f) => f.language === "sql").map((f) => f.content).join("\n"); + // Native Postgres enum tipi uretilmez (diyagram evrilince migration kâbusu olmaz). + expect(allSql).not.toContain("CREATE TYPE"); + // Fixture'da enum kolonu oldugundan en az bir CHECK ... IN (...) bulunmali. + expect(allSql).toMatch(/CHECK \("[a-z_]+" IN \('/); + }); + + /* ── RBAC DIKISI: @Roles ↔ RolesGuard (#39) ────────────────────────────── + * Eskiden @Roles metadata yaziliyordu ama OKUYAN guard yoktu (olu RBAC). Artik + * gercek RolesGuard uretilir (ROLES_KEY'i Reflector ile okur) ve @Roles kullanan + * her controller ayni route'a RolesGuard'i da baglar. Fixture'da roles-li endpoint var. */ + it("RolesGuard uretilir ve @Roles olan her controller'a wire edilir (olu RBAC degil)", () => { + const guard = files.find((f) => f.path.endsWith("shared/guards/roles.guard.ts")); + expect(guard, "roles.guard.ts was not generated").toBeDefined(); + expect(guard!.content).toContain("ROLES_KEY"); + expect(guard!.content).toContain("Reflector"); + // @Roles kullanan her controller, RolesGuard'i da import edip @UseGuards'a koymali. + let checked = 0; + for (const f of tsFiles(files)) { + if (!f.path.endsWith(".controller.ts") || !f.content.includes("@Roles(")) continue; + checked++; + expect(f.content, `${f.path}: has @Roles but RolesGuard not wired`).toContain("RolesGuard"); + } + expect(checked, "no controller using @Roles found in fixture").toBeGreaterThan(0); + }); + + /* ── CONTRACT-LINT: govde-alan write endpoint'i input DTO'su olmadan ────── + * Emitter graf'ta RequestDTORef yoksa @Body uretmez (dogru); eksik kontrat + * emitter'da uydurulmaz, codegen UYARISI olarak yuzeye cikar (canvas isaretler). + * Fixture'da RequestDTORef'siz PATCH /{id}/status endpoint'leri var. */ + it("contract-lint: govdesiz write endpoint icin codegen warning uretilir (#@Body)", () => { + const project = assembleRealisticProject(); + const bodyWarnings = project.warnings.filter((w) => /has no request body DTO/.test(w)); + expect(bodyWarnings.length, "expected body-less write endpoint warning").toBeGreaterThan(0); + }); + + /* ── STATE MACHINE DIKISI (L2): gecisli enum -> guard + servis grounding ── + * Fixture'da OrderStatus.Transitions var -> enum dosyasi assertTransition + * guard'i icerir; UpdateStatus'lu OrderService bu guard'i import eder (AI fill'i + * illegal durum gecisini reddetsin diye). */ + it("gecisli enum assert guard'i uretir + status servisi import eder (L2)", () => { + const enumFile = files.find((f) => f.path.endsWith("order-status.enum.ts")); + expect(enumFile, "order-status.enum.ts not found").toBeDefined(); + expect(enumFile!.content).toContain("ORDER_STATUS_TRANSITIONS"); + expect(enumFile!.content).toContain("export function assertOrderStatusTransition"); + const orderSvc = files.find((f) => f.path.endsWith("order/order.service.ts")); + expect(orderSvc, "order.service.ts not found").toBeDefined(); + expect(orderSvc!.content).toContain("assertOrderStatusTransition"); + }); +}); diff --git a/apps/server/src/codegen/codegen-deps-warmup.service.ts b/apps/server/src/codegen/codegen-deps-warmup.service.ts new file mode 100644 index 0000000..afd97f2 --- /dev/null +++ b/apps/server/src/codegen/codegen-deps-warmup.service.ts @@ -0,0 +1,33 @@ +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { ensureFillDepsCache } from "./codegen-fill-deps"; + +/* ──────────────────────────────────────────────────────────────────────── + * codegen-deps-warmup.service.ts — Warms surgical fill verified-deps cache at STARTUP + * (once) -> first fill is ready immediately, "verified" mode guaranteed. + * + * Runs in background, does NOT BLOCK BOOT (npm install can take minutes; transient + * npm/network issues must not take down entire backend). ensureFillDepsCache is memoized, + * so first fill while warm still in progress joins SAME in-flight install (no double install). + * + * When cache cannot be set up: EXPLICIT warning + fill service does NOT silently produce draft; + * stops with ERR_FILL_UNVERIFIED (user won't hit "clean in app, tsc error locally" surprise). + * ──────────────────────────────────────────────────────────────────────── */ +@Injectable() +export class CodegenDepsWarmupService implements OnModuleInit { + private readonly logger = new Logger(CodegenDepsWarmupService.name); + + onModuleInit(): void { + // void: warm without blocking boot. ensureFillDepsCache memoized -> no race with fill. + void ensureFillDepsCache(this.logger).then((dir) => { + if (dir) { + this.logger.log(`Surgical fill verified-deps cache ready at ${dir}`); + } else { + this.logger.warn( + "Surgical fill verified-deps cache UNAVAILABLE at startup — in-app fill will refuse with " + + "ERR_FILL_UNVERIFIED (no silent draft) until resolved. Set SOLARCH_FILL_DEPS_CACHE to a " + + "writable path with npm reachable (persistent volume in prod). The CLI channel still verifies locally.", + ); + } + }); + } +} diff --git a/apps/server/src/codegen/codegen-fill-deps.ts b/apps/server/src/codegen/codegen-fill-deps.ts new file mode 100644 index 0000000..e76017f --- /dev/null +++ b/apps/server/src/codegen/codegen-fill-deps.ts @@ -0,0 +1,78 @@ +import { spawn } from "node:child_process"; +import { access, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Logger } from "@nestjs/common"; +import { fillDepsPackageJson } from "./emitters/nestjs/scaffold.emitter"; + +/* ──────────────────────────────────────────────────────────────────────── + * codegen-fill-deps.ts — warm node_modules cache for VERIFIED in-app fill. + * + * Server needs deps to run tsc/jest; `npm install` per fill is slow + flaky. Codegen + * always emits same NestJS+TypeORM SUPERSET, so install superset ONCE in cache dir + * and symlink into each fill's temp dir. + * + * Canonical package.json comes directly from codegen's buildPackageJson + * (fillDepsPackageJson) -> cache covers every package generated code can import; + * new dep auto-included when added (no drift). + * + * When install fails (no npm / offline) returns null -> caller falls back to --skip-verify DRAFT + * path (draft without verification; CLI/VS Code channel stays tsc-proven). + * ──────────────────────────────────────────────────────────────────────── */ + +/** Cache root directory. Set via env to point at persistent volume in prod. */ +export const FILL_DEPS_CACHE_DIR = + process.env.SOLARCH_FILL_DEPS_CACHE ?? join(tmpdir(), "solarch-fill-deps"); + +let ensurePromise: Promise | null = null; + +/** Set up cache (once) and return root path where node_modules lives; null if + * cannot install. Memoized: concurrent fills join single install. On failure promise + * reset -> next fill retries (no permanent lock). */ +export function ensureFillDepsCache(logger?: Logger): Promise { + if (!ensurePromise) { + ensurePromise = buildCache(logger).catch((e) => { + logger?.warn(`fill deps cache unavailable → draft mode: ${(e as Error).message}`); + ensurePromise = null; + return null; + }); + } + return ensurePromise; +} + +async function buildCache(logger?: Logger): Promise { + const nodeModules = join(FILL_DEPS_CACHE_DIR, "node_modules"); + if (await exists(nodeModules)) return FILL_DEPS_CACHE_DIR; + + await mkdir(FILL_DEPS_CACHE_DIR, { recursive: true }); + await writeFile(join(FILL_DEPS_CACHE_DIR, "package.json"), fillDepsPackageJson()); + logger?.log(`building fill deps cache (one-time npm install) at ${FILL_DEPS_CACHE_DIR}…`); + await npmInstall(FILL_DEPS_CACHE_DIR); + if (!(await exists(nodeModules))) throw new Error("npm install finished but node_modules missing"); + logger?.log("fill deps cache ready"); + return FILL_DEPS_CACHE_DIR; +} + +function npmInstall(cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { + cwd, + env: process.env, + }); + let err = ""; + child.stderr.on("data", (d) => { + err += String(d); + }); + child.on("error", reject); // npm not on PATH + child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`npm install exit ${code}: ${err.slice(0, 300)}`)))); + }); +} + +async function exists(p: string): Promise { + try { + await access(p); + return true; + } catch { + return false; + } +} diff --git a/apps/server/src/codegen/codegen-fill.service.spec.ts b/apps/server/src/codegen/codegen-fill.service.spec.ts new file mode 100644 index 0000000..33c0cd6 --- /dev/null +++ b/apps/server/src/codegen/codegen-fill.service.spec.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from "vitest"; +import { CodegenFillService, type FillEvent } from "./codegen-fill.service"; + +/* ──────────────────────────────────────────────────────────────────────── + * codegen-fill.service.spec.ts — persistRegion: DB reflects region FINAL state. + * + * A region may emit "filled" first (initial fill, imports unresolved -> real type + * error hidden) then "violation" (repair resolves imports -> error visible, model + * cannot fix) in same flow. persistRegion: filled -> upsert, violation/error -> deleteOne + * (broken body reverts to stub; stub COMPILES). Otherwise non-compiling body would persist. + * ──────────────────────────────────────────────────────────────────────── */ + +const PROJECT = "00000000-0000-4000-8000-000000000000"; + +function build() { + const fills = { upsert: vi.fn(async () => {}), deleteOne: vi.fn(async () => {}) }; + const codegen = {}; + const svc = new CodegenFillService(codegen as never, fills as never); + // access private method (behavior lock). + const persist = (ev: Extract) => + (svc as unknown as { persistRegion(p: string, e: unknown): Promise }).persistRegion(PROJECT, ev); + return { fills, persist }; +} + +const region = (over: Partial>): Extract => ({ + event: "region", + status: "filled", + nodeId: "node-1", + member: "GetVideo", + file: "src/video/video.service.ts", + attempts: 1, + ...over, +}); + +describe("CodegenFillService.persistRegion", () => { + it("filled + body -> upsert (body persisted)", async () => { + const { fills, persist } = build(); + await persist(region({ status: "filled", body: "return dto;" })); + expect(fills.upsert).toHaveBeenCalledWith(PROJECT, "node-1", "GetVideo", "return dto;", expect.any(String)); + expect(fills.deleteOne).not.toHaveBeenCalled(); + }); + + it("violation -> deleteOne (broken body removed -> reverts to stub)", async () => { + const { fills, persist } = build(); + await persist(region({ status: "violation", violations: ["type error (TS2322): ..."] })); + expect(fills.deleteOne).toHaveBeenCalledWith(PROJECT, "node-1", "GetVideo"); + expect(fills.upsert).not.toHaveBeenCalled(); + }); + + it("error -> deleteOne (failed region stays stub)", async () => { + const { fills, persist } = build(); + await persist(region({ status: "error", error: "LLM failed" })); + expect(fills.deleteOne).toHaveBeenCalledWith(PROJECT, "node-1", "GetVideo"); + }); + + it("filled->violation ORDER: write then delete -> final stub (3-day GetVideo bug)", async () => { + const { fills, persist } = build(); + await persist(region({ status: "filled", body: "videoUrl: video.videoUrl" })); // initial fill (hidden TS2322) + await persist(region({ status: "violation" })); // repair could not fix + expect(fills.upsert).toHaveBeenCalledTimes(1); + expect(fills.deleteOne).toHaveBeenCalledTimes(1); // net result: region deleted (stub) + }); + + it("does nothing when nodeId missing (nothing to persist)", async () => { + const { fills, persist } = build(); + await persist(region({ nodeId: undefined, status: "filled", body: "x" })); + expect(fills.upsert).not.toHaveBeenCalled(); + expect(fills.deleteOne).not.toHaveBeenCalled(); + }); + + it("filled but no body -> does not write (empty body not persisted)", async () => { + const { fills, persist } = build(); + await persist(region({ status: "filled", body: undefined })); + expect(fills.upsert).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/codegen/codegen-fill.service.ts b/apps/server/src/codegen/codegen-fill.service.ts new file mode 100644 index 0000000..e1443c0 --- /dev/null +++ b/apps/server/src/codegen/codegen-fill.service.ts @@ -0,0 +1,193 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { mkdtemp, mkdir, writeFile, readFile, rm, symlink, unlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { CodegenService } from "./codegen.service"; +import { SurgicalFillRepository } from "./surgical-fill.repository"; +import { ensureFillDepsCache } from "./codegen-fill-deps"; +import type { CodegenTarget, GeneratedFile } from "./types"; + +/** Eszamanli doldurulacak DOSYA sayisi (env ile ayarlanir; ayni dosyanin bolgeleri + * her zaman sirali). */ +const FILL_PARALLEL = Math.max(1, Number.parseInt(process.env.SOLARCH_FILL_PARALLEL ?? "6", 10) || 6); + +/** Resolve the @solarch/cli `fill` entry point. It runs as a subprocess so the server's + * dependency tree stays clean (no ts-morph/ast-core in-process) while the full fill engine + * (tool-calling agent + validators) is reused as-is. + * Order: explicit SOLARCH_CLI_ENTRY env → installed @solarch/cli (the default in this + * monorepo / Docker image) → sibling solarch-tools checkout (local tooling dev). */ +function resolveCliEntry(): string { + if (process.env.SOLARCH_CLI_ENTRY) return process.env.SOLARCH_CLI_ENTRY; + try { + return require.resolve("@solarch/cli"); + } catch { + return join(process.cwd(), "..", "solarch-tools", "packages", "cli", "dist", "index.js"); + } +} +const CLI_ENTRY = resolveCliEntry(); + +/** Fill akis olaylari — SSE'ye birebir map'lenir. */ +export type FillEvent = + | { event: "start"; fileCount: number; markerCount: number } + | { event: "mode"; verified: boolean; withTests: boolean; reason?: string } + | { event: "region"; status: string; nodeId?: string; member: string; file: string; attempts: number; violations?: string[]; error?: string; body?: string } + | { event: "phase"; kind: string; round?: number; ok?: boolean; errorCount?: number; file?: string; member?: string; files?: number; skipped?: boolean } + // GOZLEM: fill ajaninin tool eylemi (read/grep/glob/lookup_members/verify_fill). KALICI NOT — + // yalniz canli akar (persistRegion'a girmez). Ozet GUVENLI (kod govdesi / secret deger NONE). + | { event: "activity"; member: string; file: string; tool: string; summary: string; ok?: boolean; attempt?: number } + | { event: "report"; filled: number; violations: number; errors: number; typecheck?: { ok: boolean }; tests?: { ok: boolean; skipped?: boolean } } + | { event: "files"; files: GeneratedFile[] } + | { event: "error"; message: string; code?: string }; + +/** Surgical AI (sunucu-tarafi) — Constructor iskeletinin `@solarch:surgical` + * bolgelerini AI'la doldurur. Akis: assemble (DB'siz) → gecici dizine yaz → + * sicak deps cache'ini node_modules olarak symlink'le → `solarch fill --all + * --parallel N --json` subprocess'i (DOGRULANMIS: tsc dongude, opsiyonel jest) → + * NDJSON ilerleme + faz olaylarini stream et → dolu dosyalari geri oku → temizle. + * + * Dogrulama: deps cache (codegen-fill-deps) kurulabildiyse temp dizine node_modules + * symlink edilir → CLI gercek `tsc` kosar, hatali bolgeleri onarir (parallel). Cache + * yoksa (npm yok / offline) `--skip-verify` TASLAK yoluna dusulur + `mode` olayi + * verified:false der. jest ("derin dogrula") opsiyoneldir (withTests). */ +@Injectable() +export class CodegenFillService { + private readonly logger = new Logger(CodegenFillService.name); + + constructor( + private readonly codegen: CodegenService, + private readonly surgicalFills: SurgicalFillRepository, + ) {} + + async *fill( + projectId: string, + target: CodegenTarget, + signal?: AbortSignal, + opts?: { withTests?: boolean }, + ): AsyncGenerator { + // service.generate proje yoksa NotFoundException atar → controller'a duser. + const project = await this.codegen.generate(projectId, target); + const markerCount = project.files.reduce((s, f) => s + (f.surgicalMarkers ?? 0), 0); + if (markerCount === 0) { + yield { event: "report", filled: 0, violations: 0, errors: 0 }; + yield { event: "files", files: project.files }; + return; + } + + const dir = await mkdtemp(join(tmpdir(), "solarch-fill-")); + try { + for (const f of project.files) { + const abs = join(dir, f.path); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, f.content); + } + yield { event: "start", fileCount: project.files.length, markerCount }; + + // Dogrulama bagimliliklari: sicak cache'i node_modules olarak symlink'le. + // SESSIZ DRAFT NONE — verified saglanamiyorsa acik, tekrar-denenebilir hata ver + // ("app'te temiz, lokalde tsc hatasi" surprizini engelle). Cache startup'ta warm + // edilir (CodegenDepsWarmupService); bu hata yalniz warm henuz bitmediyse/basarisizsa. + const depsDir = await ensureFillDepsCache(this.logger); + let verified = false; + let unverifiedReason = "verified-deps cache unavailable (npm/network at startup)"; + if (depsDir) { + try { + await symlink(join(depsDir, "node_modules"), join(dir, "node_modules"), "dir"); + verified = true; + } catch (e) { + unverifiedReason = `node_modules symlink failed: ${(e as Error).message}`; + } + } + if (!verified) { + this.logger.warn(`fill refused (unverified): ${unverifiedReason}`); + yield { + event: "error", + code: "ERR_FILL_UNVERIFIED", + message: `Verified fill is temporarily unavailable (${unverifiedReason}). Please try again in a moment — or use the CLI, which verifies locally.`, + }; + return; + } + + const withTests = opts?.withTests === true; + yield { event: "mode", verified: true, withTests }; + + const args = [CLI_ENTRY, "fill", "--all", "--parallel", String(FILL_PARALLEL), "--json"]; + if (withTests) args.push("--with-tests"); + const child = spawn(process.execPath, args, { + cwd: dir, + env: process.env, + }); + const onAbort = () => child.kill("SIGTERM"); + signal?.addEventListener("abort", onAbort); + + let stderr = ""; + child.stderr.on("data", (d) => { + stderr += String(d); + }); + + // stdout NDJSON — satir satir parse et; dolan bolgeyi ANINDA kalici sakla, sonra yield. + const rl = createInterface({ input: child.stdout }); + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + let ev: FillEvent; + try { + ev = JSON.parse(trimmed) as FillEvent; + } catch { + continue; // json olmayan gurultu — atla + } + // Bolge doldugu an govdeyi bolge-bazinda kalici uygula (re-open dolu gorunur, + // yarida kesilse de elde olan kalir). En iyi caba; hata fill'i bozmaz. + if (ev.event === "region" && ev.nodeId) { + await this.persistRegion(projectId, ev); + } + yield ev; + } + const code: number = await new Promise((res) => child.on("close", (c) => res(c ?? 0))); + signal?.removeEventListener("abort", onAbort); + if (code !== 0 && stderr) this.logger.warn(`fill subprocess exit ${code}: ${stderr.slice(0, 400)}`); + + // Dolu dosyalari geri oku (tumunu; degismeyenler aynen doner). + const filled = await Promise.all( + project.files.map(async (f) => { + try { + return { ...f, content: await readFile(join(dir, f.path), "utf8") }; + } catch { + return f; + } + }), + ); + yield { event: "files", files: filled }; + } finally { + // FIRST node_modules symlink'ini AYRI kaldir → rm asla paylasilan cache'e + // (symlink hedefine) inmesin. (fs.rm symlink'i izlemez ama kasitli garanti.) + await unlink(join(dir, "node_modules")).catch(() => {}); + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } + } + + /** Bir "region" olayini DB'ye kalici uygula — DB bolgenin FINAL durumunu yansitsin. + * + * Bir bolge AYNI fill akisinda once "filled" sonra "violation" emit edilebilir: + * ilk-dolum import'lar cozulmeden tip-denetler (gercek tip hatasi "Cannot find name" + * arkasina saklanir → "filled"); repair fazinda import'lar cozulunce hata gorunur ve + * model cozemezse bolge "violation"a duser. Bu yuzden: + * - "filled" → govdeyi yaz/uzerine yaz (gecerli sonuc). + * - "violation"/"error" → sakli (kirik) govdeyi SIL → bolge stub'a doner (stub DERLENIR, + * derlenmeyen govde KALICI NOT). 3 gundur GetVideo TS2322'sinin saklanma sebebi: + * "filled" kaydediliyor ama sonraki "violation" yok sayiliyordu → kirik govde kaliyordu. + * En iyi caba: persist hatasi fill akisini bozmaz. */ + private async persistRegion(projectId: string, ev: Extract): Promise { + if (!ev.nodeId) return; + if (ev.status === "filled" && ev.body) { + await this.surgicalFills + .upsert(projectId, ev.nodeId, ev.member, ev.body, new Date().toISOString()) + .catch((e) => this.logger.warn(`surgical fill persist failed (${ev.member}): ${(e as Error).message}`)); + } else if (ev.status === "violation" || ev.status === "error") { + await this.surgicalFills + .deleteOne(projectId, ev.nodeId, ev.member) + .catch((e) => this.logger.warn(`surgical fill revert failed (${ev.member}): ${(e as Error).message}`)); + } + } +} diff --git a/apps/server/src/codegen/codegen-tsc.gate.test.ts b/apps/server/src/codegen/codegen-tsc.gate.test.ts new file mode 100644 index 0000000..95f3253 --- /dev/null +++ b/apps/server/src/codegen/codegen-tsc.gate.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { spawn } from "node:child_process"; +import { mkdtemp, mkdir, writeFile, rm, symlink, unlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { assembleRealisticFixture } from "./__fixtures__/load"; +import { ensureFillDepsCache } from "./codegen-fill-deps"; + +/* ──────────────────────────────────────────────────────────────────────── + * codegen-tsc.gate.test.ts — BUTUN-PROJE TSC GECIDI ("compiles out of the box"). + * + * Gercekci grafi assemble eder → gecici dizine yazar → sicak deps cache'ini + * (ensureFillDepsCache, in-app verified-fill ile AYNI) node_modules olarak symlink'ler + * → uretilen projeye `tsc --noEmit` kosar → 0 hata bekler. Bu, iskeletin GERCEKTEN + * derlendiginin makine-kanitidir (README'nin "compiles out of the box" sozu). + * + * AYRI calisir (`*.gate.test.ts`, `*.spec.ts` NOT → default `pnpm test`'e girmez): + * yavas + node_modules gerektirir. `pnpm test:codegen-gate` ile / CI'da kosulur. + * - Cache kurulamazsa (npm yok / offline) ATLAR (gurultulu uyari; CI npm saglamali). + * - Yerel: SOLARCH_FILL_DEPS_CACHE ile hazir bir node_modules'e isaret edilebilir. + * + * NOT: bu gecit ISKELET'i derler (govdeler `throw NOT_IMPLEMENTED`). Cast-ile-gizli + * (PK casing) ve fill-sonrasi (kardinalite) dikis bug'lari burada GORUNMEZ — onlar + * codegen-assembly.spec.ts'teki yapisal seam-assertion'lariyla kilitlenir. Iki gecit + * TOGETHER "verified, not guessed" saglar. + * ──────────────────────────────────────────────────────────────────────── */ + +const GATE_TIMEOUT = 600_000; + +describe("codegen butun-proje tsc gecidi (gercekci graf)", () => { + it( + "generated skeleton passes tsc with 0 errors", + async (ctx) => { + const files = assembleRealisticFixture(); + + const depsDir = await ensureFillDepsCache(); + if (!depsDir) { + const msg = + "verified-deps cache missing (npm/offline) -> tsc gate could not run. " + + "Locally provide ready node_modules via SOLARCH_FILL_DEPS_CACHE."; + // FALSE-GREEN KORUMASI: CI'da skip = sessiz yesil. CI'da deps SAGLANMALI; + // saglanmadiysa gecidi FAIL et (atlama yalniz yerel gelistirmede). + if (process.env.CI) throw new Error(`[tsc-gate] ${msg} gate cannot be skipped in CI.`); + console.warn(`[tsc-gate] ${msg} (yerel: ATLANDI)`); + ctx.skip(); + return; + } + + const dir = await mkdtemp(join(tmpdir(), "solarch-tsc-gate-")); + try { + for (const f of files) { + const abs = join(dir, f.path); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, f.content); + } + // Sicak cache'i node_modules olarak symlink'le (kopya yok, hizli). + await symlink(join(depsDir, "node_modules"), join(dir, "node_modules"), "dir"); + + const { code, output } = await runTsc(dir); + expect(code, `generated skeleton did NOT pass tsc:\n${output}`).toBe(0); + } finally { + // FIRST symlink'i ayri kaldir → rm paylasilan cache'e inmesin. + await unlink(join(dir, "node_modules")).catch(() => {}); + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } + }, + GATE_TIMEOUT, + ); +}); + +/** Uretilen projede `tsc --noEmit -p tsconfig.json` kosar. tsc'yi node ile dogrudan + * cagirir (.bin shim'ine degil) → platform-bagimsiz. stdout+stderr birlesik doner. */ +function runTsc(cwd: string): Promise<{ code: number; output: string }> { + return new Promise((resolve) => { + const tscEntry = join(cwd, "node_modules", "typescript", "bin", "tsc"); + const child = spawn(process.execPath, [tscEntry, "--noEmit", "-p", "tsconfig.json"], { + cwd, + env: process.env, + }); + let output = ""; + child.stdout.on("data", (d) => (output += String(d))); + child.stderr.on("data", (d) => (output += String(d))); + child.on("error", (e) => resolve({ code: 1, output: `tsc spawn error: ${e.message}` })); + child.on("close", (c) => resolve({ code: c ?? 1, output })); + }); +} diff --git a/apps/server/src/codegen/codegen.controller.spec.ts b/apps/server/src/codegen/codegen.controller.spec.ts new file mode 100644 index 0000000..5b69549 --- /dev/null +++ b/apps/server/src/codegen/codegen.controller.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NotFoundException } from "@nestjs/common"; +import { CodegenController } from "./codegen.controller"; +import { CODEGEN_VERSION } from "./codegen.version"; +import type { GeneratedProject } from "./types"; + +/* ──────────────────────────────────────────────────────────────────────── + * codegen.controller.spec.ts — version stamping + status unit tests. + * + * - successful generate -> service.generate + + * projects.setCodegenVersion(projectId, CODEGEN_VERSION) IS CALLED (stamp). + * - status: for generated null / stale / current / missing project combinations + * { current, generated, updateAvailable } is computed correctly. + * ──────────────────────────────────────────────────────────────────────── */ + +const PROJECT = "00000000-0000-4000-8000-000000000000"; +const AUTH = { userId: "user_1", orgId: null, orgRole: null } as never; + +function fakeGenerated(): GeneratedProject { + return { + target: "nestjs", + files: [], + nodeFiles: {}, + warnings: [], + summary: { + version: CODEGEN_VERSION, + fileCount: 0, + nodeCount: 0, + surgicalMarkerCount: 0, + skippedKinds: {}, + }, + }; +} + +function build(repoVersion: number | null | undefined, graphRev = 0, genGraphRev: number | null = null) { + const service = { generate: vi.fn(async () => fakeGenerated()) }; + const projects = { + setCodegenVersion: vi.fn(async () => {}), + getCodegenVersion: vi.fn(async () => repoVersion), + getGraphRevision: vi.fn(async () => graphRev), + getCodegenGraphRevision: vi.fn(async () => genGraphRev), + }; + const fill = { fill: vi.fn(async function* () {}) }; + const fills = { deleteOne: vi.fn(async () => {}) }; + const imports = { resolveImports: vi.fn(async (f: unknown) => f) }; + const controller = new CodegenController(service as never, projects as never, fill as never, fills as never, imports as never); + return { controller, service, projects, fills, imports }; +} + +describe("CodegenController — version stamping (generate)", () => { + it("successful generate -> stamps CODEGEN_VERSION on project", async () => { + const { controller, service, projects } = build(undefined); + await controller.generate(PROJECT, { target: "nestjs" } as never, AUTH); + + expect(service.generate).toHaveBeenCalledWith(PROJECT, "nestjs"); + expect(projects.setCodegenVersion).toHaveBeenCalledWith(PROJECT, CODEGEN_VERSION); + }); + + it("when generate fails, stamp does not run", async () => { + const { controller, service, projects } = build(undefined); + service.generate.mockRejectedValueOnce(new Error("ERR_PROJECT_NOT_FOUND")); + + await expect(controller.generate(PROJECT, { target: "nestjs" } as never, AUTH)).rejects.toThrow(); + expect(projects.setCodegenVersion).not.toHaveBeenCalled(); + }); +}); + +describe("CodegenController — revert (restore region to stub)", () => { + it("calls deleteOne with projectId/nodeId/member + returns ok", async () => { + const { controller, fills } = build(undefined); + const res = await controller.revertFill(PROJECT, "node-1", "LoginAsync"); + expect(fills.deleteOne).toHaveBeenCalledWith(PROJECT, "node-1", "LoginAsync"); + expect(res.data).toEqual({ reverted: true }); + }); +}); + +describe("CodegenController — status (updateAvailable logic)", () => { + let CURRENT: number; + beforeEach(() => { + CURRENT = CODEGEN_VERSION; + }); + + it("never generated (generated null) -> updateAvailable false + no drift", async () => { + const { controller } = build(null); + const res = await controller.status(PROJECT); + expect(res.data).toMatchObject({ current: CURRENT, generated: null, updateAvailable: false, diagramDrifted: false, driftCount: 0 }); + }); + + it("stale stamp (< current) -> updateAvailable true", async () => { + const { controller } = build(CURRENT - 1); + const res = await controller.status(PROJECT); + expect(res.data).toMatchObject({ current: CURRENT, generated: CURRENT - 1, updateAvailable: true }); + }); + + it("diagram drift: graphRevision > generatedGraphRevision -> diagramDrifted + driftCount", async () => { + // rev 3 stamped at generation, now rev 5 → 2 structural changes (drift). + const { controller } = build(CURRENT, 5, 3); + const res = await controller.status(PROJECT); + expect(res.data).toMatchObject({ diagramDrifted: true, driftCount: 2, graphRevision: 5, generatedGraphRevision: 3 }); + }); + + it("no drift: graphRevision == generatedGraphRevision", async () => { + const { controller } = build(CURRENT, 4, 4); + const res = await controller.status(PROJECT); + expect(res.data).toMatchObject({ diagramDrifted: false, driftCount: 0 }); + }); + + it("current stamp (= current) -> updateAvailable false", async () => { + const { controller } = build(CURRENT); + const res = await controller.status(PROJECT); + expect(res.data).toMatchObject({ current: CURRENT, generated: CURRENT, updateAvailable: false }); + }); + + it("missing project (getCodegenVersion undefined) -> 404 ERR_PROJECT_NOT_FOUND", async () => { + const { controller } = build(undefined); + await expect(controller.status(PROJECT)).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/apps/server/src/codegen/codegen.controller.ts b/apps/server/src/codegen/codegen.controller.ts new file mode 100644 index 0000000..7508571 --- /dev/null +++ b/apps/server/src/codegen/codegen.controller.ts @@ -0,0 +1,307 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + HttpCode, + NotFoundException, + UseGuards, + Sse, + Query, + Req, + type MessageEvent, +} from "@nestjs/common"; +import { from, type Observable } from "rxjs"; +import { map } from "rxjs/operators"; +import { ApiTags, ApiOperation, ApiParam, ApiResponse, type OpenAPIObject } from "@nestjs/swagger"; +import { Throttle } from "@nestjs/throttler"; +import { ProjectAccessGuard } from "../auth/project-access.guard"; +import { env } from "../config/env"; +import { CurrentAuth } from "../auth/current-auth.decorator"; +import type { AuthContext } from "../auth/auth.types"; +import { ProjectsRepository } from "../projects/projects.repository"; +import { CodegenService } from "./codegen.service"; +import type { SystemMapDTO, SimpleSketchModel } from "./simple-projection"; +import { CodegenFillService, type FillEvent } from "./codegen-fill.service"; +import { ImportResolverService } from "./import-resolver.service"; +import { SurgicalFillRepository } from "./surgical-fill.repository"; +import { CodegenRequestDto } from "./dto/codegen.dto"; +import { CODEGEN_VERSION } from "./codegen.version"; +import { ok } from "../common/envelope"; +import type { SuccessEnvelope } from "../common/envelope"; +import type { GeneratedProject } from "./types"; + +/** Extract code+message from HttpException body (SSE error event). */ +function errBody(e: unknown): { code?: string; message: string } { + const resp = (e as { getResponse?: () => unknown }).getResponse?.(); + if (resp && typeof resp === "object") { + const r = resp as { code?: string; message?: string }; + return { code: r.code, message: r.message ?? "Error" }; + } + return { message: (e as Error)?.message ?? "Error" }; +} + +/** Constructor version status + diagram drift — frontend builds "Codebase improved" / + * "diagram changed" badges from this. */ +export interface CodegenStatus { + /** Current Constructor version (CODEGEN_VERSION). */ + current: number; + /** Version stamped on the project; null if never generated. */ + generated: number | null; + /** generated != null && generated < current -> a better scaffold exists. */ + updateAvailable: boolean; + /** Project's current structural graph revision (increments on node/edge changes). */ + graphRevision: number; + /** Graph revision stamped at generation time; null if never generated. */ + generatedGraphRevision: number | null; + /** DIAGRAM DRIFT: has the diagram structurally changed since generation + * (generatedGraphRevision != null && graphRevision > generatedGraphRevision). + * NOTE: this is NOT code↔diagram AST drift (that requires local code, in CLI) — + * signals "generated code lags the current diagram"; reminds user to regenerate. */ + diagramDrifted: boolean; + /** Count of structural changes since generation ( > 0 when diagramDrifted). */ + driftCount: number; +} + +@ApiTags("Codegen") +@UseGuards(ProjectAccessGuard) +@Controller("projects/:projectId/codegen") +export class CodegenController { + constructor( + private readonly service: CodegenService, + private readonly projects: ProjectsRepository, + private readonly fill: CodegenFillService, + private readonly fills: SurgicalFillRepository, + private readonly imports: ImportResolverService, + ) {} + + /** Revert — delete stored (AI/human) body for ONE region. Next generate restores that + * region to NOT_IMPLEMENTED stub. Idempotent (200 if already absent). Frontend + * rail "Revert to stub" calls this then re-generates. */ + @Delete("fill/:nodeId/:member") + @HttpCode(200) + @ApiOperation({ summary: "Revert a filled surgical region back to its stub" }) + @ApiParam({ name: "nodeId", description: "Node UUID of the region" }) + @ApiParam({ name: "member", description: "Method/member name" }) + async revertFill( + @Param("projectId") projectId: string, + @Param("nodeId") nodeId: string, + @Param("member") member: string, + ): Promise> { + await this.fills.deleteOne(projectId, nodeId, member); + return ok({ reverted: true }); + } + + /** Simple View — READ-ONLY projection of the architecture graph for non-developers: + * feature map (boxes + "uses"/"triggers" arrows) + per-feature capability cards + + * logic diagram. Does NOT generate code, no AI (like viewing the canvas). Same graph → same output (sibling of Mermaid export). */ + @Get("simple-view") + @ApiOperation({ + summary: "Non-developer 'Simple View' projection of the architecture graph", + description: + "Read-only, deterministic projection for non-technical stakeholders: a feature map " + + "(boxes + 'uses'/'triggers' arrows) and per-feature capability cards with logic-flow " + + "diagrams. No code generated, no AI — free. Same graph -> byte-identical output.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "`data: SystemMap` — features[], arrows[], shared?." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async simpleView(@Param("projectId") projectId: string): Promise> { + return ok(await this.service.simpleView(projectId)); + } + + /** Simple SKETCH — Mermaid for the hand-drawn (Excalidraw-style) Simple view. AI + * (DeepSeek) refines a deterministic baseline into a friendlier non-dev diagram and + * the result is cached per project (LLM runs only when the graph changes); falls back + * to the deterministic baseline when AI is unavailable. */ + @Get("simple-sketch") + @ApiOperation({ + summary: "Mermaid for the hand-drawn Simple sketch (AI-refined, cached, deterministic fallback)", + description: "Returns `{ mermaid, source }` for the non-developer sketch view.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "`data: { mermaid: string; source: 'ai' | 'deterministic' }`." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async simpleSketch(@Param("projectId") projectId: string): Promise> { + return ok(await this.service.simpleSketch(projectId)); + } + + /** Structured Simple-View model — Mermaid-free `{ nodes, edges, groups }` the client lays + * out with ELK and renders with rough.js (the new tool-calling generation path). */ + @Get("simple-sketch-model") + @ApiOperation({ summary: "Structured Simple-View model (Mermaid-free; ELK-laid-out client-side)" }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "`data: { model: { nodes, edges, groups }, source, aiConfigured }`." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async simpleSketchModel( + @Param("projectId") projectId: string, + @Query("stage") stage?: "baseline" | "full", + ): Promise> { + return ok(await this.service.simpleSketchModel(projectId, stage === "baseline" ? "baseline" : "full")); + } + + /** Regenerate the Simple-View model — bypass the per-project cache and re-run the AI refine. + * Powers the "Regenerate" button: when a previous run fell back to deterministic (an AI hiccup) + * and the graph hasn't changed, this forces a fresh attempt instead of returning the cached one. */ + @Post("simple-sketch-model/regenerate") + @HttpCode(200) + @ApiOperation({ summary: "Regenerate the Simple-View model (bypass cache, re-run the AI refine)" }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "`data: { model, source, aiConfigured }` — freshly regenerated." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async regenerateSimpleSketchModel( + @Param("projectId") projectId: string, + ): Promise> { + return ok(await this.service.simpleSketchModel(projectId, "full", true)); + } + + /** OpenAPI document — a deterministic, graph-true OpenAPI 3.1 spec the client renders with Scalar. + * `?stage=baseline` returns the instant deterministic doc (no AI); otherwise the persisted + * AI-enriched doc is served while the graph is unchanged, falling back to the deterministic baseline + * when the AI is off or fails. The AI only annotates EXISTING operations/schemas (prose + examples) — + * it never invents paths. */ + @Get("openapi.json") + @ApiOperation({ + summary: "OpenAPI 3.1 document for the architecture graph (Scalar-rendered, AI-documentized, cached)", + description: "Returns `{ doc, source, aiConfigured }`. `?stage=baseline` skips the AI for an instant deterministic doc.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "`data: { doc: OpenAPIObject; source: 'ai' | 'deterministic'; aiConfigured: boolean }`." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async openApi( + @Param("projectId") projectId: string, + @Query("stage") stage?: "baseline" | "full", + ): Promise> { + return ok(await this.service.apiDoc(projectId, stage === "baseline" ? "baseline" : "full")); + } + + /** AI Documentize — bypass the per-project cache and re-run the AI enrichment over the OpenAPI doc. + * Powers the "AI Documentize" button: forces a fresh prose/example pass even when the graph hasn't + * changed (e.g. a previous run fell back to deterministic on an AI hiccup). The structure stays + * graph-true; only descriptions/examples on existing operations/schemas change. */ + @Post("openapi/documentize") + @HttpCode(200) + @ApiOperation({ summary: "AI Documentize the OpenAPI doc (bypass cache, re-run the grounded enrichment)" }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "`data: { doc, source, aiConfigured }` — freshly documentized." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async documentizeOpenApi( + @Param("projectId") projectId: string, + ): Promise> { + return ok(await this.service.apiDoc(projectId, "full", true)); + } + + @Post() + @HttpCode(200) + @ApiOperation({ + summary: "Generate deterministic code from the architecture graph (Constructor)", + description: + "From the TechnicalGraph (node + edge), generates a deterministic NestJS + TypeScript " + + "code scaffold **without AI**. The backend chain (Module/Controller/Service/Repository/DTO/Model/" + + "Table/Enum/Exception) is fully generated; the other 12 types become stubs with surgical markers. " + + "Method bodies are marked with `@solarch:surgical` markers (the algorithm area — " + + "the SURGICAL AI that fills these is separate/future). " + + "The same graph -> byte-identical output.\n\n", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "`data: { target, files[], summary }`." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async generate( + @Param("projectId") projectId: string, + @Body() body: CodegenRequestDto, + @CurrentAuth() _auth: AuthContext, + ): Promise> { + const target = (body as { target?: "nestjs" }).target ?? "nestjs"; + const project = await this.service.generate(projectId, target); + // BOUNDARY: AI=algorithm, system=imports. generate re-injects stored BODIES but does not + // add imports (owned types/operators) → "Cannot find name". When filled regions exist, + // resolve imports deterministically (best-effort; on error return project unchanged). When + // no filled regions (fresh scaffold already has imports) skip → avoid temp-dir cost. + if (project.files.some((f) => f.content.includes("@solarch:filled"))) { + project.files = await this.imports.resolveImports(project.files); + } + // STAMPING: after successful generation write current Constructor version on project. + await this.projects.setCodegenVersion(projectId, CODEGEN_VERSION); + return ok(project); + } + + @Sse("fill/stream") + @Throttle({ default: { ttl: 60_000, limit: env.CODEGEN_FILL_THROTTLE_LIMIT } }) + @ApiOperation({ + summary: "Surgical AI — fill the @solarch:surgical bodies (SSE)", + description: + "Opens an EventSource. Generates the deterministic skeleton, then fills every `@solarch:surgical` " + + "method body in parallel with the verification-driven fill agent (grounding + contract + declared-throws + " + + "import-fix), then runs real `tsc` over the project and repairs failing regions in a loop. " + + "Pass `?jest=true` to also generate+run behavioural specs (slower). Event types:\n" + + "- `event: start` — { fileCount, markerCount }\n" + + "- `event: mode` — { verified, withTests, reason? } verified=false ⇒ deps cache unavailable, draft only\n" + + "- `event: region` — { status: filled|violation|error, member, file, attempts }\n" + + "- `event: phase` — { kind: verify|repair|imports|tests, … } the live tsc/repair loop\n" + + "- `event: report` — { filled, violations, errors, typecheck?, tests? }\n" + + "- `event: files` — { files[] } the full filled project\n" + + "- `event: error` — { code, message }", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + fillStream( + @Param("projectId") projectId: string, + @CurrentAuth() auth: AuthContext, + @Req() req: { on(event: "close", cb: () => void): void }, + @Query("target") targetQ?: string, + @Query("jest") jestQ?: string, + ): Observable { + const target = targetQ === "nestjs" ? "nestjs" : "nestjs"; // single target for now + // jest ("deep verify") optional: tsc always in loop; jest is slow+costly → toggle. + const withTests = jestQ === "true" || jestQ === "1"; + const ac = new AbortController(); + req.on("close", () => ac.abort()); + const self = this; + + async function* guarded(): AsyncGenerator { + try { + for await (const ev of self.fill.fill(projectId, target, ac.signal, { withTests })) { + yield ev; + } + await self.projects.setCodegenVersion(projectId, CODEGEN_VERSION).catch(() => {}); + } catch (e) { + yield { event: "error", ...errBody(e) }; + } + } + + return from(guarded()).pipe(map((ev: FillEvent) => ({ type: ev.event, data: ev }) as MessageEvent)); + } + + @Get("status") + @ApiOperation({ + summary: "Constructor version status", + description: + "Compares the current Constructor version with the version STAMPED on the project. " + + "If `generated` is null the project has never generated code (nothing to update). " + + "`updateAvailable` is true only if the stamped version is older than the current one — the frontend " + + "uses this to show the 'Your codebase is now better' (Update) badge in the top bar.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "`data: { current, generated, updateAvailable, graphRevision, generatedGraphRevision, diagramDrifted, driftCount }`." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async status( + @Param("projectId") projectId: string, + ): Promise> { + const generated = await this.projects.getCodegenVersion(projectId); + if (generated === undefined) { + throw new NotFoundException({ + code: "ERR_PROJECT_NOT_FOUND", + message: `Project '${projectId}' not found.`, + }); + } + const current = CODEGEN_VERSION; + const updateAvailable = generated !== null && generated < current; + // Diagram drift: difference between graphRevision at generation vs now (structural change count). + const graphRevision = await this.projects.getGraphRevision(projectId); + const generatedGraphRevision = await this.projects.getCodegenGraphRevision(projectId); + const diagramDrifted = generatedGraphRevision !== null && graphRevision > generatedGraphRevision; + const driftCount = diagramDrifted ? graphRevision - generatedGraphRevision! : 0; + return ok({ current, generated, updateAvailable, graphRevision, generatedGraphRevision, diagramDrifted, driftCount }); + } +} diff --git a/apps/server/src/codegen/codegen.module.ts b/apps/server/src/codegen/codegen.module.ts new file mode 100644 index 0000000..6d16681 --- /dev/null +++ b/apps/server/src/codegen/codegen.module.ts @@ -0,0 +1,18 @@ +import { Module } from "@nestjs/common"; +import { CodegenController } from "./codegen.controller"; +import { CodegenService } from "./codegen.service"; +import { CodegenFillService } from "./codegen-fill.service"; +import { CodegenDepsWarmupService } from "./codegen-deps-warmup.service"; +import { ImportResolverService } from "./import-resolver.service"; +import { SurgicalFillRepository } from "./surgical-fill.repository"; +import { ProjectsModule } from "../projects/projects.module"; +import { NodesModule } from "../nodes/nodes.module"; +import { EdgesModule } from "../edges/edges.module"; + +@Module({ + imports: [ProjectsModule, NodesModule, EdgesModule], + controllers: [CodegenController], + providers: [CodegenService, CodegenFillService, CodegenDepsWarmupService, ImportResolverService, SurgicalFillRepository], + exports: [CodegenService], +}) +export class CodegenModule {} diff --git a/apps/server/src/codegen/codegen.service.spec.ts b/apps/server/src/codegen/codegen.service.spec.ts new file mode 100644 index 0000000..3ead39c --- /dev/null +++ b/apps/server/src/codegen/codegen.service.spec.ts @@ -0,0 +1,1006 @@ +import { describe, it, expect } from "vitest"; +import { CodegenService, applySurgicalFills } from "./codegen.service"; +import type { GeneratedFile } from "./types"; +import { CODEGEN_VERSION } from "./codegen.version"; +import { buildCodeGraph } from "./ir"; +import { EMITTER_REGISTRY } from "./emitters/nestjs"; +import type { StoredNode } from "../nodes/nodes.repository"; +import type { StoredEdge } from "../edges/edges.repository"; +import type { NodeKind } from "../nodes/schemas"; +import type { EdgeKind } from "../edges/schemas/edge.schema"; +import type { GeneratedProject } from "./types"; + +/* ──────────────────────────────────────────────────────────────────────── + * codegen.service.spec.ts — entegrasyon testi. + * + * Gercekci 9-node'luk fixture uzerinden TUM montaj zincirini dogrular: + * Module(UsersModule) ── ExposedServices: [UsersService] + * UsersController ─CALLS─> UsersService ─CALLS─> UserRepository ─WRITES─> users(Table) + * + CreateUserDto + UserDto + UserRole(Enum) + UserNotFoundException + User(Model) + * + * Assertion'lar: + * - dogru dosyalar uretildi (feature klasoru = "users") + * - Controller->Service DI'i CALLS edge'inden geldi (Controller semasinda ref NONE) + * - Service DI'i = Dependencies UNION CALLS + * - surgical marker sayisi > 0 + * - import'lar cozumlendi (goreli yollar) + * - DETERMINISM: same graph twice -> byte-identical JSON + * ──────────────────────────────────────────────────────────────────────── */ + +const PROJECT_ID = "00000000-0000-4000-8000-000000000000"; +const TAB_ID = "11111111-1111-4111-8111-111111111111"; + +let seq = 0; +/** Deterministik UUID uretici (test ici). */ +function uid(): string { + seq += 1; + const h = seq.toString(16).padStart(12, "0"); + return `aaaaaaaa-aaaa-4aaa-8aaa-${h}`; +} + +function node(type: NodeKind, properties: Record): StoredNode { + return { + id: uid(), + type, + projectId: PROJECT_ID, + positionX: 0, + positionY: 0, + homeTabId: TAB_ID, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function edge(kind: EdgeKind, source: StoredNode, target: StoredNode): StoredEdge { + return { + id: uid(), + projectId: PROJECT_ID, + sourceNodeId: source.id, + targetNodeId: target.id, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: false }, + }; +} + +/* ── Fixture insasi ──────────────────────────────────────────────────────── */ +function buildFixture(): { nodes: StoredNode[]; edges: StoredEdge[] } { + const usersTable = node("Table", { + TableName: "users", + Description: "User table", + Columns: [ + { Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + { Name: "email", DataType: "VARCHAR", Length: 255, IsPrimaryKey: false, IsNotNull: true, IsUnique: true, AutoIncrement: false }, + { Name: "role", DataType: "ENUM", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false, EnumRef: "UserRole" }, + ], + ForeignKeys: [], + UniqueConstraints: [], + CheckConstraints: [], + Indexes: [], + }); + + const userRole = node("Enum", { + Name: "UserRole", + Description: "User rolu", + BackingType: "string", + Values: [{ Key: "ADMIN" }, { Key: "MEMBER" }], + }); + + const userModel = node("Model", { + ClassName: "User", + Description: "User varligi", + TableRef: "users", + Properties: [ + { Name: "id", Type: "string", IsNullable: false, IsCollection: false }, + { Name: "email", Type: "string", IsNullable: false, IsCollection: false }, + ], + Methods: [], + }); + + const createUserDto = node("DTO", { + Name: "CreateUserDto", + Description: "User olusturma girdisi", + Fields: [ + { Name: "email", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [{ Rule: "Email" }] }, + { Name: "role", DataType: "UserRole", IsRequired: true, IsArray: false, ValidationRules: [], EnumRef: "UserRole" }, + ], + }); + + const userDto = node("DTO", { + Name: "UserDto", + Description: "User yaniti", + Fields: [ + { Name: "id", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [] }, + { Name: "email", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [{ Rule: "Email" }] }, + ], + }); + + const notFoundExc = node("Exception", { + ExceptionName: "UserNotFoundException", + Description: "User bulunamadi", + HttpStatusCode: 404, + LogSeverity: "Warning", + ErrorCode: "ERR_USER_NOT_FOUND", + }); + + const userRepository = node("Repository", { + RepositoryName: "UserRepository", + Description: "User veri erisimi", + EntityReference: "User", + IsCached: false, + CustomQueries: [ + { QueryName: "findByEmail", QueryType: "findOne", Parameters: [{ Name: "email", Type: "string" }], ReturnType: "User" }, + ], + }); + + const usersService = node("Service", { + ServiceName: "UsersService", + Description: "User is mantigi", + IsTransactionScoped: true, + Methods: [ + { + MethodName: "create", + Visibility: "public", + Parameters: [{ Name: "dto", Type: "CreateUserDto", Optional: false, DtoRef: "CreateUserDto" }], + ReturnType: "UserDto", + ReturnDtoRef: "UserDto", + IsAsync: true, + Throws: ["UserNotFoundException"], + }, + ], + Dependencies: [{ Kind: "Repository", Ref: "UserRepository" }], + }); + + const usersController = node("Controller", { + ControllerName: "UsersController", + Description: "User HTTP arayuzu", + BaseRoute: "users", + Version: "v1", + Endpoints: [ + { + HttpMethod: "POST", + Route: "/", + RequestDTORef: "CreateUserDto", + ResponseDTORef: "UserDto", + RequiresAuth: true, + RequiredRoles: [], + PathParams: [], + QueryParams: [], + StatusCodes: [{ Code: 201 }], + MiddlewareRefs: [], + }, + { + HttpMethod: "GET", + Route: "/:id", + ResponseDTORef: "UserDto", + RequiresAuth: true, + RequiredRoles: [], + PathParams: [{ Name: "id", Type: "string" }], + QueryParams: [], + StatusCodes: [{ Code: 200 }], + MiddlewareRefs: [], + }, + ], + }); + + const usersModule = node("Module", { + ModuleName: "UsersModule", + Description: "User modulu", + StrictBoundaries: true, + ExposedServices: ["UsersService"], + Dependencies: [], + }); + + const nodes = [ + usersTable, + userRole, + userModel, + createUserDto, + userDto, + notFoundExc, + userRepository, + usersService, + usersController, + usersModule, + ]; + + const edges = [ + // CRITICAL: Controller->Service yalniz CALLS edge'inden gelir. + edge("CALLS", usersController, usersService), + // Service -> Repository (Dependencies + CALLS birlesimi test edilir). + edge("CALLS", usersService, userRepository), + // Repository -> Table (WRITES). + edge("WRITES", userRepository, usersTable), + // Module -> Service (USES) — moduleOf icin ek bag. + edge("USES", usersModule, usersService), + // Service -> Exception (THROWS). + edge("THROWS", usersService, notFoundExc), + ]; + + return { nodes, edges }; +} + +function generate(): GeneratedProject { + const { nodes, edges } = buildFixture(); + const graph = buildCodeGraph(nodes, edges); + const service = new CodegenService(null as never, null as never, null as never, null as never); + return service.assemble(graph, "nestjs"); +} + +describe("CodegenService (orchestrator entegrasyon)", () => { + const project = generate(); + const fileByPath = new Map(project.files.map((f) => [f.path, f])); + const path = (p: string) => { + const f = fileByPath.get(p); + if (!f) throw new Error(`Expected file missing: ${p}\nGenerated:\n${[...fileByPath.keys()].join("\n")}`); + return f; + }; + + it("dogru cekirdek dosyalari uretildi (users feature klasoru, idiomatik isimler)", () => { + const paths = [...fileByPath.keys()]; + // Feature/TS dosyalari montajda "src/" altina toplanir (scaffold + tsconfig + // include ile tek agac). SQL migration'lari KOKTE kalir (derlenmez). + // ARCHITECTURE-FARKINDA: dosya adlari rol son-ekini TEKRARLAMAZ (users.controller.ts, + // user.repository.ts), feature basina TEK module (users.module.ts). + expect(paths).toContain("src/users/users.module.ts"); + expect(paths).toContain("src/users/users.controller.ts"); + expect(paths).toContain("src/users/users.service.ts"); + expect(paths).toContain("src/users/user.repository.ts"); + // Model entity bagli Table ("users") ile ayni feature'da (DI co-location). + expect(paths).toContain("src/users/entities/user.entity.ts"); + // DTO'lar onlari tuketen Controller/Service'in feature'inda (users/dto). + expect(paths).toContain("src/users/dto/create-user.dto.ts"); + expect(paths).toContain("src/users/dto/user.dto.ts"); + // Exception THROWS eden UsersService ile ayni feature'da (users/exceptions). + expect(paths).toContain("src/users/exceptions/user-not-found.exception.ts"); + // Enum (paylasimli kabul edilir) -> common/enums. + expect(paths).toContain("src/common/enums/user-role.enum.ts"); + // TableName "users" fiziksel ad sayilir (cogullanmaz) -> "001_create_users.sql". + expect(paths).toContain("migrations/001_create_users.sql"); + }); + + it("scaffold (proje-genel) dosyalari uretildi", () => { + const paths = [...fileByPath.keys()]; + expect(paths).toContain("package.json"); + expect(paths).toContain("tsconfig.json"); + expect(paths).toContain("tsconfig.build.json"); + expect(paths).toContain("nest-cli.json"); + expect(paths).toContain("src/main.ts"); + expect(paths).toContain("src/app.module.ts"); + // H3/H1: cekirdek altyapi + global filter. + expect(paths).toContain("src/core/core.module.ts"); + expect(paths).toContain("src/shared/filters/all-exceptions.filter.ts"); + // H4: .env.example KOKTE (src/ altinda degil). + expect(paths).toContain(".env.example"); + expect(paths).not.toContain("src/.env.example"); + // H5: TypeORM CLI data-source. + expect(paths).toContain("src/data-source.ts"); + // H6: test/CI iskeleti. + expect(paths).toContain("test/app.e2e-spec.ts"); + expect(paths).toContain(".gitignore"); + expect(paths).toContain("README.md"); + }); + + it("H5: SQL migration basina calistirilabilir TypeORM TS migration uretildi", () => { + const paths = [...fileByPath.keys()]; + // users tablosu -> migrations/001_create_users.sql (ham referans) + + // src/migrations/-CreateUsers.ts (TypeORM gelenegi: -.ts). + expect(paths).toContain("migrations/001_create_users.sql"); + const tsMig = paths.find((p) => /^src\/migrations\/\d{13}-Create\w+\.ts$/.test(p)); + expect(tsMig).toBeDefined(); + const mig = path(tsMig!).content; + expect(mig).toContain("implements MigrationInterface"); + expect(mig).toContain("public async up(queryRunner: QueryRunner)"); + expect(mig).toContain("public async down(queryRunner: QueryRunner)"); + expect(mig).toContain('CREATE TABLE "users"'); + expect(mig).toContain('DROP TABLE IF EXISTS "users" CASCADE'); + // TypeORM, sinif adinin SON 13 hanesini timestamp olarak parseInt eder + // (MigrationExecutor: name.substr(-13)); salt "001" soneki NaN -> CLI firlatir. + // Sinif adi 13-haneli zaman damgasiyla bitmeli ve parseInt edilebilmeli. + const className = mig.match(/export class (\w+) implements MigrationInterface/)?.[1]; + expect(className).toBeDefined(); + const last13 = className!.slice(-13); + expect(last13).toMatch(/^\d{13}$/); + expect(Number.isNaN(Number.parseInt(last13, 10))).toBe(false); + }); + + it("DTO'lar uretildi (RequestDTORef/ResponseDTORef cozulebilir)", () => { + const paths = [...fileByPath.keys()]; + expect(paths.some((p) => p.endsWith("create-user.dto.ts"))).toBe(true); + expect(paths.some((p) => p.endsWith("/user.dto.ts"))).toBe(true); + }); + + it("Controller->Service DI'i CALLS edge'inden geldi (semada ref yok)", () => { + const controller = path("src/users/users.controller.ts").content; + // constructor injection UsersService uzerinden + expect(controller).toContain("constructor("); + expect(controller).toContain("private readonly usersService: UsersService"); + // UsersService import'u goreli yoldan cozuldu (ayni feature klasoru) + expect(controller).toMatch(/import \{ UsersService \} from "\.\/users\.service"/); + }); + + it("Controller route Version oneki + endpoint metotlari + auth guard", () => { + const controller = path("src/users/users.controller.ts").content; + expect(controller).toContain('@Controller("v1/users")'); + expect(controller).toContain("@Post()"); + expect(controller).toContain('@Get(":id")'); + expect(controller).toContain("@HttpCode(201)"); + expect(controller).toContain("@UseGuards(AuthGuard)"); + // RequestDTORef cozuldu -> @Body() dto: CreateUserDto + expect(controller).toContain("@Body() dto: CreateUserDto"); + }); + + it("Service DI'i = Dependencies UNION CALLS (UserRepository tek kez)", () => { + const svc = path("src/users/users.service.ts").content; + expect(svc).toContain("@Injectable()"); + expect(svc).toContain("constructor("); + // Repository hem Dependencies'te hem CALLS edge'inde -> tek injection (dedup) + const repoInjections = svc.match(/userRepository: UserRepository/g) ?? []; + expect(repoInjections.length).toBe(1); + // import cozuldu (ayni feature) + expect(svc).toMatch(/from "\.\/user\.repository"/); + }); + + it("Repository @InjectRepository + entity import cozuldu", () => { + const repo = path("src/users/user.repository.ts").content; + expect(repo).toContain("@Injectable()"); + // EntityReference -> User Model entity import'u (entity ayni feature'da -> ./entities). + expect(repo).toMatch(/from "\.\/entities\/user\.entity"/); + // CustomQuery -> async imza + surgical marker + expect(repo).toContain("findByEmail"); + expect(repo).toContain("@solarch:surgical"); + }); + + it("Table migration Postgres DDL + ENUM kolon", () => { + const sql = path("migrations/001_create_users.sql"); + expect(sql.language).toBe("sql"); + expect(sql.content).toContain('CREATE TABLE "users"'); + // Entity @Entity adi ile migration tablo adi AYNI (ayrismaz). + const entity = path("src/users/entities/user.entity.ts").content; + expect(entity).toContain('@Entity("users")'); + expect(sql.surgicalMarkers).toBe(0); + }); + + it("Feature module SYNTHESIZED: @Module dekoratoru + DI listeleri (repository kayitli)", () => { + const mod = path("src/users/users.module.ts").content; + expect(mod).toContain("@Module("); + expect(mod).toContain("controllers: [UsersController],"); + // providers repository'yi de icerir -> DI tam, uygulama BOOT BOOTS. + expect(mod).toContain("providers: [UsersService, UserRepository],"); + expect(mod).toContain("TypeOrmModule.forFeature([User])"); + expect(mod).toContain("export class UsersModule {}"); + }); + + it("app.module feature modulunu import eder (ham controller/provider NOT)", () => { + const app = path("src/app.module.ts").content; + expect(app).toContain('import { UsersModule } from "./users/users.module";'); + expect(app).toContain(" UsersModule,"); + // Ham controller/provider app.module'e GIRMEZ. + expect(app).not.toContain("controllers:"); + expect(app).not.toContain("providers:"); + expect(app).not.toContain("UsersController"); + }); + + it("surgical marker sayisi > 0 (Service/Controller/Repository govdeleri)", () => { + expect(project.summary.surgicalMarkerCount).toBeGreaterThan(0); + const total = project.files.reduce((s, f) => s + f.surgicalMarkers, 0); + expect(total).toBe(project.summary.surgicalMarkerCount); + }); + + it("summary: fileCount/nodeCount dogru, skippedKinds bos (12 tip yok)", () => { + expect(project.summary.nodeCount).toBe(10); + expect(project.summary.fileCount).toBe(project.files.length); + // Fixture'da desteklenmeyen 12 tip NONE -> skippedKinds bos. + expect(project.summary.skippedKinds).toEqual({}); + }); + + it("summary.version = CODEGEN_VERSION = 6 (cikti kendi nesli ile etiketlenir)", () => { + expect(project.summary.version).toBe(CODEGEN_VERSION); + expect(CODEGEN_VERSION).toBe(6); + }); + + it("SURGICAL_PLAN.md uretildi (proje KOKUNDE, markdown, Ingilizce prompt)", () => { + const plan = path("SURGICAL_PLAN.md"); + expect(plan.language).toBe("markdown"); + // Proje KOKUNDE (src/ altinda NOT). + expect([...fileByPath.keys()]).toContain("SURGICAL_PLAN.md"); + expect([...fileByPath.keys()]).not.toContain("src/SURGICAL_PLAN.md"); + // Iki bolum + kapanis talimati. + expect(plan.content).toContain("## 1. Codebase introduction"); + expect(plan.content).toContain("## 2. Surgical implementation plan"); + expect(plan.content).toContain("## Instructions"); + // Codebase tanitimi: NestJS + Solarch. + expect(plan.content).toContain("NestJS"); + expect(plan.content).toContain("Solarch"); + // Feature listesi graph'tan (fixture'da "users" feature'i var). + expect(plan.content).toContain("`users`"); + // Plan, uretilen marker'lari gorur: UsersService.create govdesi listelenir. + expect(plan.content).toContain("src/users/users.service.ts"); + expect(plan.content).toContain("Implement:"); + // MD'nin KENDI marker'i NONE (plan metnidir) -> surgicalMarkers 0. + expect(plan.surgicalMarkers).toBe(0); + }); + + it("dosyalar path'e gore sirali (determinizm)", () => { + const paths = project.files.map((f) => f.path); + const sorted = [...paths].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + expect(paths).toEqual(sorted); + }); + + it("her dosya tek '\\n' ile biter", () => { + for (const f of project.files) { + expect(f.content.endsWith("\n")).toBe(true); + expect(f.content.endsWith("\n\n")).toBe(false); + } + }); + + it("DETERMINISM: same graph twice -> byte-identical JSON", () => { + const { nodes, edges } = buildFixture(); + const graph = buildCodeGraph(nodes, edges); + const service = new CodegenService(null as never, null as never, null as never, null as never); + const a = service.assemble(graph, "nestjs"); + const b = service.assemble(graph, "nestjs"); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + }); + + it("SIRA-DEGISMEZLIGI: girdi node/edge sirasi ters cevrilince cikti AYNI", () => { + // Gercek nondeterminizm kaynagi DB'nin SIRASIZ list()'idir; IR her seyi + // yeniden siralar. Bu test, siralamanin TAM (stabil tiebreak'li) oldugunu + // KANITLAR: ayni node'lar ters sirada verildiginde cikti birebir ayni kalmali. + const { nodes, edges } = buildFixture(); + const service = new CodegenService(null as never, null as never, null as never, null as never); + + const forward = service.assemble(buildCodeGraph(nodes, edges), "nestjs"); + const reversed = service.assemble( + buildCodeGraph([...nodes].reverse(), [...edges].reverse()), + "nestjs", + ); + expect(JSON.stringify(reversed.files)).toBe(JSON.stringify(forward.files)); + }); + + it("SIRA-DEGISMEZLIGI: rastgele karistirilmis girdi -> byte-identical cikti", () => { + const { nodes, edges } = buildFixture(); + const service = new CodegenService(null as never, null as never, null as never, null as never); + const base = service.assemble(buildCodeGraph(nodes, edges), "nestjs"); + + // Deterministik (seed'siz ama sabit) bir permutasyon: index'e gore dondur. + const rotate = (arr: T[], by: number): T[] => { + const k = ((by % arr.length) + arr.length) % arr.length; + return [...arr.slice(k), ...arr.slice(0, k)]; + }; + for (const by of [1, 3, 5, 7]) { + const shuffled = service.assemble( + buildCodeGraph(rotate(nodes, by), rotate(edges, by)), + "nestjs", + ); + expect(JSON.stringify(shuffled.files)).toBe(JSON.stringify(base.files)); + } + }); + + it("auth guard + roles decorator stub'lari uretildi (controller import'lari cozulur)", () => { + // Controller RequiresAuth=true + RequiredRoles olan endpoint kullanir; + // import edilen stub dosyalar montajda gercekten uretilmeli (TS2307 onlenir). + const paths = [...fileByPath.keys()]; + // shared/ standardizasyonu: guard/decorator artik shared/ altinda (common/ degil). + expect(paths).toContain("src/shared/guards/auth.guard.ts"); + const guard = path("src/shared/guards/auth.guard.ts").content; + expect(guard).toContain("export class AuthGuard"); + expect(guard).toContain("implements CanActivate"); + }); +}); + +describe("CodegenService.generate — secret redaksiyonu (defense-in-depth)", () => { + it("nodes.list redaksiyon yapmasa da codegen sinirinda secret IR'a girmez", async () => { + // Legacy: write-guard oncesi yazilmis secret EnvironmentVariable (duz-metin + // DefaultValue). Repository.list redaksiyon yapmaz; generate() sinirda redakte + // etmeli, boylece koruma yapisaldir (her emitter'in IsSecret kontrolune bagli degil). + const PROJECT = "00000000-0000-4000-8000-00000000abcd"; + const secretNode: StoredNode = { + id: "aaaaaaaa-aaaa-4aaa-8aaa-000000000abc", + type: "EnvironmentVariable", + projectId: PROJECT, + positionX: 0, + positionY: 0, + homeTabId: TAB_ID, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties: { + Key: "JWT_SECRET", + Description: "imza anahtari", + DataType: "String", + IsSecret: true, + Environment: ["Prod"], + DefaultValue: "super-secret-plaintext-value", + IsRequired: true, + }, + }; + + let observed: StoredNode[] | null = null; + const projects = { exists: async () => true } as never; + const nodes = { + list: async () => [secretNode], // RAW (redaksiyonsuz) — repository davranisi. + } as never; + const edges = { list: async () => [] } as never; + const surgicalFills = { getAllForProject: async () => [] } as never; + + const service = new CodegenService(projects, nodes, edges, surgicalFills); + // assemble'i sarmalayip generate'in ona verdigi graph'i yakala. + const origAssemble = service.assemble.bind(service); + service.assemble = ((graph, target) => { + observed = graph.nodes.map((n) => ({ ...n })); + return origAssemble(graph, target); + }) as typeof service.assemble; + + const project = await service.generate(PROJECT, "nestjs"); + + // Duz-metin secret HICBIR uretilen dosyada gorunmemeli. + for (const f of project.files) { + expect(f.content).not.toContain("super-secret-plaintext-value"); + } + // IR'a giren node'un DefaultValue'su redakte edilmis olmali (bos). + expect(observed).not.toBeNull(); + const envNode = observed!.find((n) => n.type === "EnvironmentVariable"); + expect((envNode!.properties as Record).DefaultValue).toBe(""); + }); +}); + +describe("CodegenService — mimari altyapi tam uretim (Cache/Worker artik supported)", () => { + it("Cache + Worker -> GERCEK kod (stub NOT) + skippedKinds bos", () => { + const cache = node("Cache", { + CacheName: "SessionCache", + Description: "Session cache", + KeyPattern: "session:{id}", + TTL_Seconds: 3600, + Engine: "Redis", + EvictionPolicy: "LRU", + }); + const worker = node("Worker", { + WorkerName: "EmailWorker", + Description: "E-posta kuyrugu iscisi", + Schedule: "0 * * * *", + TaskToExecute: "Send pending emails", + }); + const graph = buildCodeGraph([cache, worker], []); + const service = new CodegenService(null as never, null as never, null as never, null as never); + const project = service.assemble(graph, "nestjs"); + + // Artik GERCEK kod uretilir; .stub.ts NONE. + expect(project.files.some((f) => f.path.endsWith(".cache.ts"))).toBe(true); + expect(project.files.some((f) => f.path.endsWith(".worker.ts"))).toBe(true); + expect(project.files.some((f) => f.path.endsWith(".stub.ts"))).toBe(false); + // Cache/Worker artik supported -> skippedKinds bos. + expect(project.summary.skippedKinds).toEqual({}); + // Worker @Cron handler'i surgical marker tasir. + expect(project.summary.surgicalMarkerCount).toBeGreaterThan(0); + + // nodeFiles haritasi: her node kendi dosyasina eslenir. + expect(project.nodeFiles[cache.id]?.some((p) => p.endsWith(".cache.ts"))).toBe(true); + expect(project.nodeFiles[worker.id]?.some((p) => p.endsWith(".worker.ts"))).toBe(true); + + // H3: root forRoot/register artik CoreModule'de (app.module degil). + const core = project.files.find((f) => f.path === "src/core/core.module.ts")!.content; + // Worker -> ScheduleModule.forRoot() (@Cron ateslensin). + expect(core).toContain("ScheduleModule.forRoot()"); + // Cache -> CacheModule app root'a kaydedilir. + expect(core).toContain("CacheModule.register({ isGlobal: true })"); + // package.json gerekli deps'i aldi (graph-farkinda). + const pkg = project.files.find((f) => f.path === "package.json")!.content; + expect(pkg).toContain("@nestjs/cache-manager"); + expect(pkg).toContain("@nestjs/schedule"); + }); + + it("EnvironmentVariable -> .stub.ts URETILMEZ (config; tek temsil .env.example), skippedKinds'e sayilir", () => { + const env = node("EnvironmentVariable", { + Key: "DATABASE_URL", + Description: "DB baglantisi", + DataType: "String", + IsSecret: false, + Environment: ["Prod"], + IsRequired: true, + }); + const graph = buildCodeGraph([env], []); + const service = new CodegenService(null as never, null as never, null as never, null as never); + const project = service.assemble(graph, "nestjs"); + + // Bir ortam degiskeni kod modulu NOT -> anlamsiz `export class XStub {}` uretilmez. + expect(project.files.some((f) => f.path.includes("environment-variable"))).toBe(false); + expect(project.files.some((f) => f.path.endsWith(".stub.ts"))).toBe(false); + // Tek temsili .env.example (H4: KOKTE). + const envExample = project.files.find((f) => f.path === ".env.example"); + expect(envExample?.content).toContain("DATABASE_URL"); + // Kayitsiz kind -> skippedKinds'e sayilir (sessizce dusmez). + expect(project.summary.skippedKinds).toEqual({ EnvironmentVariable: 1 }); + }); +}); + +describe("CodegenService — fault-isolation (M5: bozuk node TUM codegen'i dusurmez)", () => { + it("emitter PATLARSA o node skippedKinds'e sayilir, geri kalan graph uretilir", () => { + // Cache emitter'i (supported) tek bir node'da patlatilir. Beklenen: Cache + // dosyasi URETILMEZ + skippedKinds.Cache=1; ama Worker (saglam) emit edilir + // ve scaffold/feature montaji kesilmez. Try/catch yoksa assemble TUMUYLE + // throw eder ve hicbir dosya cikmazdi. + const cache = node("Cache", { + CacheName: "SessionCache", + Description: "x", + KeyPattern: "session:{id}", + TTL_Seconds: 3600, + Engine: "Redis", + }); + const worker = node("Worker", { + WorkerName: "EmailWorker", + Description: "x", + Schedule: "0 * * * *", + TaskToExecute: "Send pending items", + }); + const graph = buildCodeGraph([cache, worker], []); + const service = new CodegenService(null as never, null as never, null as never, null as never); + + // Cache emitter'ini GECICI olarak patlat (gercek "emitter undefined alana + // patlar" senaryosunu simule eder), sonra mutlaka geri yukle. + const entry = EMITTER_REGISTRY.Cache!; + const original = entry.emit; + entry.emit = () => { + throw new TypeError("Cannot read properties of undefined (simulated)"); + }; + let project: GeneratedProject; + try { + project = service.assemble(graph, "nestjs"); + } finally { + entry.emit = original; + } + + // Bozuk Cache atlandi: dosya NONE + skippedKinds'e sayildi (sessizce dusmedi). + expect(project.files.some((f) => f.path.endsWith(".cache.ts"))).toBe(false); + expect(project.summary.skippedKinds).toEqual({ Cache: 1 }); + + // Saglam Worker emit edilmeye devam etti (codegen dusmedi). + expect(project.files.some((f) => f.path.endsWith(".worker.ts"))).toBe(true); + // Scaffold montaji da kesilmedi. + expect(project.files.some((f) => f.path === "src/main.ts")).toBe(true); + expect(project.files.some((f) => f.path === "src/app.module.ts")).toBe(true); + }); + + it("DETERMINISM: ayni bozuk node iki kez -> byte-identical cikti (hangi node patlar sabittir)", () => { + const cache = node("Cache", { CacheName: "C", Description: "x" }); + const worker = node("Worker", { WorkerName: "W", Description: "x", Schedule: "0 * * * *", TaskToExecute: "t" }); + const service = new CodegenService(null as never, null as never, null as never, null as never); + const entry = EMITTER_REGISTRY.Cache!; + const original = entry.emit; + entry.emit = () => { + throw new Error("boom"); + }; + try { + const a = service.assemble(buildCodeGraph([cache, worker], []), "nestjs"); + const b = service.assemble(buildCodeGraph([cache, worker], []), "nestjs"); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + } finally { + entry.emit = original; + } + }); + + it("adi WITHOUT (name-property string degil/bos) bozuk node atlanir + sayilir, GARBAGE uretmez", () => { + // Gercek bozuk girdi: ServiceName bir SAYI, Methods bir DIZI NOT. ir.toCodeNode + // name'i ""e zorlar; orchestrator bos-adli node'u atlar. Emitter cagrilmadan + // skippedKinds'e sayilir -> gecersiz "export class { }" + "undefined()" GARBAGE + // CIKMAZ; saglam node uretilmeye devam eder (feature-inference de patlamaz). + const broken = node("Service", { ServiceName: 12345, Methods: "not-an-array", Dependencies: null }); + const good = node("Service", { + ServiceName: "GoodService", + Description: "ok", + Methods: [{ MethodName: "ok", Parameters: [], ReturnType: "void", IsAsync: true }], + Dependencies: [], + }); + const service = new CodegenService(null as never, null as never, null as never, null as never); + const project = service.assemble(buildCodeGraph([broken, good], []), "nestjs"); + + // Bozuk node skippedKinds'e sayildi; hicbir dosya uretmedi. + expect(project.summary.skippedKinds).toEqual({ Service: 1 }); + expect(project.nodeFiles[broken.id]).toBeUndefined(); + // GARBAGE yok: bos sinif adi ya da "undefined()" metodu iceren dosya uretilmemeli. + const tsContent = project.files.filter((f) => f.language === "typescript").map((f) => f.content).join("\n"); + expect(tsContent).not.toMatch(/export class\s+\{/); + expect(tsContent).not.toContain("undefined(): void"); + // Saglam servis hâlâ uretildi (codegen dusmedi). + expect(project.files.some((f) => f.path === "src/good/good.service.ts")).toBe(true); + }); +}); + +describe("CodegenService — warnings ciktiya tasinir (M4: dongusel module import)", () => { + it("karsilikli feature CALLS (A<->B): dongu kirilir + project.warnings'e uyari yazilir", () => { + // alpha <-> beta servisleri karsilikli CALLS -> module import dongusu. Orchestrator + // bir geri-kenari forwardRef ile kirar (kenar KORUNUR, lazy emit) ve bunu + // project.warnings'te BILDIRIR; eskiden uyari yalniz graph icinde kalip kaybolurdu. + const alphaCtrl = node("Controller", { ControllerName: "AlphaController", Description: "a", BaseRoute: "alpha", Endpoints: [] }); + const alphaSvc = node("Service", { ServiceName: "AlphaService", Description: "a", Dependencies: [], Methods: [] }); + const betaCtrl = node("Controller", { ControllerName: "BetaController", Description: "b", BaseRoute: "beta", Endpoints: [] }); + const betaSvc = node("Service", { ServiceName: "BetaService", Description: "b", Dependencies: [], Methods: [] }); + const edges = [ + edge("CALLS", alphaCtrl, alphaSvc), + edge("CALLS", betaCtrl, betaSvc), + edge("CALLS", alphaSvc, betaSvc), // alpha -> beta + edge("CALLS", betaSvc, alphaSvc), // beta -> alpha (karsilikli) + ]; + const service = new CodegenService(null as never, null as never, null as never, null as never); + const project = service.assemble(buildCodeGraph([alphaCtrl, alphaSvc, betaCtrl, betaSvc], edges), "nestjs"); + + expect(project.warnings).toHaveLength(1); + expect(project.warnings[0]).toContain("AlphaModule"); + expect(project.warnings[0]).toContain("BetaModule"); + expect(project.warnings[0]).toContain("forwardRef"); + }); + + it("dongu yoksa project.warnings bos dizidir", () => { + const ctrl = node("Controller", { ControllerName: "SoloController", Description: "x", BaseRoute: "solo", Endpoints: [] }); + const svc = node("Service", { ServiceName: "SoloService", Description: "x", Dependencies: [], Methods: [] }); + const service = new CodegenService(null as never, null as never, null as never, null as never); + const project = service.assemble(buildCodeGraph([ctrl, svc], [edge("CALLS", ctrl, svc)]), "nestjs"); + expect(project.warnings).toEqual([]); + }); +}); + +describe("CodegenService — module wiring EMIT dogrulamasi (Bug 1 + Bug 2 uctan-uca)", () => { + /** Uretilen dosyanin icerigini path-son-eki ile bulur. */ + function fileEndingWith(project: GeneratedProject, suffix: string): GeneratedFile { + const f = project.files.find((x) => x.path.endsWith(suffix)); + if (!f) throw new Error(`not generated: ${suffix} (existing: ${project.files.map((x) => x.path).join(", ")})`); + return f; + } + + it("Bug 1: 3'lu dongude uretilen module forwardRef(() => XModule) EMIT eder", () => { + const authCtrl = node("Controller", { ControllerName: "AuthController", Description: "x", BaseRoute: "auth", Endpoints: [] }); + const authSvc = node("Service", { ServiceName: "AuthService", Description: "x", Dependencies: [], Methods: [] }); + const chatCtrl = node("Controller", { ControllerName: "ChatController", Description: "x", BaseRoute: "chat", Endpoints: [] }); + const chatSvc = node("Service", { ServiceName: "ChatService", Description: "x", Dependencies: [], Methods: [] }); + const msgCtrl = node("Controller", { ControllerName: "MessagingController", Description: "x", BaseRoute: "messaging", Endpoints: [] }); + const msgSvc = node("Service", { ServiceName: "MessagingService", Description: "x", Dependencies: [], Methods: [] }); + const edges = [ + edge("CALLS", authCtrl, authSvc), + edge("CALLS", chatCtrl, chatSvc), + edge("CALLS", msgCtrl, msgSvc), + edge("CALLS", authSvc, chatSvc), + edge("CALLS", chatSvc, msgSvc), + edge("CALLS", msgSvc, authSvc), + ]; + const service = new CodegenService(null as never, null as never, null as never, null as never); + const project = service.assemble(buildCodeGraph([authCtrl, authSvc, chatCtrl, chatSvc, msgCtrl, msgSvc], edges), "nestjs"); + + // messaging->auth geri-kenari forwardRef olur (to="auth" en kucuk). + const msgModule = fileEndingWith(project, "messaging/messaging.module.ts"); + expect(msgModule.content).toContain("forwardRef(() => AuthModule)"); + expect(msgModule.content).toContain('import { Module, forwardRef } from "@nestjs/common"'); + // Eager kenarlar duz emit edilir (forwardRef NONE). + expect(fileEndingWith(project, "auth/auth.module.ts").content).toContain("imports: [ChatModule]"); + expect(fileEndingWith(project, "chat/chat.module.ts").content).toContain("imports: [MessagingModule]"); + }); + + it("Bug 2: property-dep'li cross-feature Repository → tuketici module sahibi import eder", () => { + const userCtrl = node("Controller", { ControllerName: "UserController", Description: "x", BaseRoute: "users", Endpoints: [] }); + const userSvc = node("Service", { ServiceName: "UserService", Description: "x", Dependencies: [], Methods: [] }); + const userRepo = node("Repository", { RepositoryName: "UserRepository", Description: "x", EntityReference: "users", CustomQueries: [] }); + const tokenCtrl = node("Controller", { ControllerName: "TokenController", Description: "x", BaseRoute: "tokens", Endpoints: [] }); + const tokenSvc = node("Service", { ServiceName: "TokenService", Description: "x", Dependencies: [{ Kind: "Repository", Ref: "UserRepository" }], Methods: [] }); + const edges = [ + edge("CALLS", userCtrl, userSvc), + edge("CALLS", userSvc, userRepo), + edge("CALLS", tokenCtrl, tokenSvc), + // tokenSvc -> userRepo CALLS edge'i NONE (yalniz property-dep). + ]; + const service = new CodegenService(null as never, null as never, null as never, null as never); + const project = service.assemble(buildCodeGraph([userCtrl, userSvc, userRepo, tokenCtrl, tokenSvc], edges), "nestjs"); + + // TokenModule UserModule'u import eder; UserModule UserRepository'yi export eder. + expect(fileEndingWith(project, "token/token.module.ts").content).toContain("UserModule"); + expect(fileEndingWith(project, "user/user.module.ts").content).toMatch(/exports:\s*\[[^\]]*UserRepository/); + }); +}); + +describe("CodegenService — Table-only graph BOOT garantisi (mimari-farkinda)", () => { + it("Model'siz Table + cross-feature Service->Repository: sentetik entity + module export -> boot eder", () => { + // image feature ImageGenerationService -CALLS-> UserRepository (auth). + // Tablolar Model'siz (Users/GeneratedImages). Beklenen: + // - Repository + string token NONE; sentetik entity import + Repository. + // - AuthModule UserRepository EXPORT eder; ImageModule AuthModule import eder. + // - app.module yalniz feature modullerini import eder (ham provider degil). + const usersTable = node("Table", { TableName: "Users", Description: "x", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }] }); + const imagesTable = node("Table", { TableName: "GeneratedImages", Description: "x", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }] }); + const userRepo = node("Repository", { RepositoryName: "UserRepository", Description: "x", EntityReference: "Users", BaseClass: "BaseRepository", CustomQueries: [{ QueryName: "FindById", QueryType: "findOne", Parameters: [{ Name: "id", Type: "UUID" }], ReturnType: "Users" }] }); + const imageRepo = node("Repository", { RepositoryName: "ImageRepository", Description: "x", EntityReference: "GeneratedImages", CustomQueries: [] }); + const authSvc = node("Service", { ServiceName: "AuthService", Description: "x", Dependencies: [{ Kind: "Repository", Ref: "UserRepository" }], Methods: [] }); + const imageSvc = node("Service", { ServiceName: "ImageGenerationService", Description: "x", Dependencies: [{ Kind: "Repository", Ref: "ImageRepository" }, { Kind: "Repository", Ref: "UserRepository" }, { Kind: "Cache", Ref: "ImageCache" }], Methods: [] }); + const imageCache = node("Cache", { CacheName: "ImageCache", Description: "x" }); + const authCtrl = node("Controller", { ControllerName: "AuthController", Description: "x", BaseRoute: "auth", Endpoints: [] }); + const imageCtrl = node("Controller", { ControllerName: "ImageController", Description: "x", BaseRoute: "image", Endpoints: [] }); + + const nodes = [usersTable, imagesTable, userRepo, imageRepo, authSvc, imageSvc, imageCache, authCtrl, imageCtrl]; + const edges = [ + edge("CALLS", authCtrl, authSvc), + edge("CALLS", imageCtrl, imageSvc), + edge("CALLS", authSvc, userRepo), + edge("CALLS", imageSvc, imageRepo), + edge("CALLS", imageSvc, userRepo), // cross-feature + edge("CALLS", imageSvc, imageCache), + edge("WRITES", userRepo, usersTable), + edge("WRITES", imageRepo, imagesTable), + ]; + const graph = buildCodeGraph(nodes, edges); + const service = new CodegenService(null as never, null as never, null as never, null as never); + const project = service.assemble(graph, "nestjs"); + const byPath = new Map(project.files.map((f) => [f.path, f])); + + // Sentetik entity'ler uretildi. + expect(byPath.has("src/auth/entities/user.entity.ts")).toBe(true); + expect(byPath.has("src/image/entities/generated-image.entity.ts")).toBe(true); + + // UserRepository sentetik entity'ye baglandi (Repository NONE). + const userRepoFile = byPath.get("src/auth/user.repository.ts")!.content; + expect(userRepoFile).toContain("Repository"); + expect(userRepoFile).not.toContain("Repository"); + expect(userRepoFile).not.toContain("extends BaseRepository"); + expect(userRepoFile).toContain("FindById(id: string)"); // UUID -> string + + // AuthModule UserRepository EXPORT eder (cross-feature DI boot eder). + const authMod = byPath.get("src/auth/auth.module.ts")!.content; + expect(authMod).toContain("exports: [UserRepository],"); + expect(authMod).toContain("TypeOrmModule.forFeature([User])"); + + // ImageModule: AuthModule import + sentetik entity forFeature + Cache provider. + const imageMod = byPath.get("src/image/image.module.ts")!.content; + expect(imageMod).toContain("AuthModule"); + expect(imageMod).toContain("TypeOrmModule.forFeature([GeneratedImage])"); + // Cache artik TAM emitter -> gercek provider sinifi (Stub eki NONE). + expect(imageMod).toContain("ImageCache"); + expect(imageMod).not.toContain("ImageCacheStub"); + // CacheModule.register() feature module imports'a girer (CACHE_MANAGER token). + expect(imageMod).toContain("CacheModule.register()"); + + // app.module yalniz feature modullerini import eder. + const app = byPath.get("src/app.module.ts")!.content; + expect(app).toContain("AuthModule,"); + expect(app).toContain("ImageModule,"); + expect(app).not.toContain("providers:"); + }); +}); + +describe("CodegenService — orphan-prevention (B2 gateway / B3 common / B4 config)", () => { + it("B2: APIGateway gercek @Controller -> feature module controllers'ina girer (orphan degil)", () => { + // Gateway -CALLS-> UsersService -> gateway, users feature'ina duser; gercek + // @Controller olarak users.module controllers'ina girer (UsersController ile). + const usersSvc = node("Service", { ServiceName: "UsersService", Description: "x", Methods: [], Dependencies: [] }); + const usersCtrl = node("Controller", { ControllerName: "UsersController", Description: "x", BaseRoute: "users", Endpoints: [] }); + const gateway = node("APIGateway", { + GatewayName: "PublicApiGateway", Description: "giris", Provider: "Kong", + Routes: [{ Path: "/public/users", TargetRef: "UsersService", Methods: ["GET"], AuthRequired: false }], + }); + const nodes = [usersSvc, usersCtrl, gateway]; + const edges = [edge("CALLS", usersCtrl, usersSvc), edge("CALLS", gateway, usersSvc)]; + const graph = buildCodeGraph(nodes, edges); + const project = new CodegenService(null as never, null as never, null as never, null as never).assemble(graph, "nestjs"); + const byPath = new Map(project.files.map((f) => [f.path, f])); + + // Gateway dosyasi users feature'inda + gercek @Controller (Injectable NOT). + const gw = byPath.get("src/users/public.gateway.ts"); + expect(gw).toBeDefined(); + expect(gw!.content).toContain("@Controller()"); + expect(gw!.content).not.toContain("@Injectable()"); + // Service enjekte eder (Controller degil -> anti-pattern yok). + expect(gw!.content).toContain("private readonly usersService: UsersService,"); + expect(gw!.content).not.toContain("UsersController"); + + // users.module controllers'ina gateway GIRER (orphan KALMAZ). + const mod = byPath.get("src/users/users.module.ts")!.content; + expect(mod).toContain("controllers: [UsersController, PublicApiGateway],"); + }); + + it("B3: common/'a dusen MessageQueue+EventHandler+Cache -> CommonModule sentezlenir + AppModule import", () => { + // Hicbir feature'a baglanamayan altyapi -> common. CommonModule onlari toplar, + // BullModule.registerQueue + CacheModule.register() wiring'i yapar; AppModule + // import eder -> hicbiri orphan kalmaz. + const queue = node("MessageQueue", { QueueName: "EventsQueue", Description: "x", Type: "Queue", Provider: "RabbitMQ", MessageFormat: "EventDto" }); + const handler = node("EventHandler", { HandlerName: "EventsHandler", Description: "x", EventName: "evt", IsAsync: true, QueueRef: "EventsQueue" }); + const cache = node("Cache", { CacheName: "SharedCache", Description: "x", KeyPattern: "k:{id}", TTL_Seconds: 60, Engine: "Redis" }); + const nodes = [queue, handler, cache]; + const edges = [edge("SUBSCRIBES", handler, queue)]; + const graph = buildCodeGraph(nodes, edges); + const project = new CodegenService(null as never, null as never, null as never, null as never).assemble(graph, "nestjs"); + const byPath = new Map(project.files.map((f) => [f.path, f])); + + // CommonModule sentezlendi. + const common = byPath.get("src/common/common.module.ts"); + expect(common).toBeDefined(); + // BullModule.registerQueue GERCEKTEN cagrilir (sessiz basarisizlik NOT). + expect(common!.content).toContain("BullModule.registerQueue({ name: EVENTS_QUEUE })"); + expect(common!.content).toContain("CacheModule.register()"); + // Tum common altyapi provider'lari @Module.providers'a girer (orphan degil). + expect(common!.content).toContain("providers: [EventsHandler, EventsQueue, SharedCache]"); + + // AppModule CommonModule'u import eder. + const app = byPath.get("src/app.module.ts")!.content; + expect(app).toContain('import { CommonModule } from "./common/common.module";'); + expect(app).toContain("CommonModule,"); + }); + + it("B4: CoreModule ConfigModule.forRoot(validationSchema) + TypeORM forRootAsync + env.validation.ts (DATABASE_URL required)", () => { + const ctrl = node("Controller", { ControllerName: "PingController", Description: "x", BaseRoute: "ping", Endpoints: [] }); + const graph = buildCodeGraph([ctrl], []); + const project = new CodegenService(null as never, null as never, null as never, null as never).assemble(graph, "nestjs"); + const byPath = new Map(project.files.map((f) => [f.path, f])); + + // H3: root forRoot CoreModule'de; app.module ince (yalniz CoreModule + feature). + const app = byPath.get("src/app.module.ts")!.content; + expect(app).toContain(" CoreModule,"); + expect(app).not.toContain("ConfigModule.forRoot"); + + const core = byPath.get("src/core/core.module.ts")!.content; + expect(core).toContain("ConfigModule.forRoot({ isGlobal: true, validationSchema })"); + expect(core).toContain("TypeOrmModule.forRootAsync({"); + expect(core).toContain('config.getOrThrow("DATABASE_URL")'); + + // env.validation.ts (Joi) DAIMA uretilir; DATABASE_URL zorunlu (fail-fast). + const v = byPath.get("src/config/env.validation.ts"); + expect(v).toBeDefined(); + expect(v!.content).toContain('import Joi from "joi";'); + expect(v!.content).toContain("DATABASE_URL: Joi.string().required(),"); + + // package.json @nestjs/config + joi icerir. + const pkg = byPath.get("package.json")!.content; + expect(pkg).toContain('"@nestjs/config"'); + expect(pkg).toContain('"joi"'); + + // main.ts fail-fast + ConfigService + Pino logger (H2). + const main = byPath.get("src/main.ts")!.content; + expect(main).toContain("bufferLogs: true"); + expect(main).toContain("app.get(ConfigService)"); + expect(main).toContain("app.useLogger(app.get(Logger))"); + + // tsconfig strict:true (B1). + const tsconfig = byPath.get("tsconfig.json")!.content; + expect(tsconfig).toContain('"strict": true'); + }); +}); + +describe("applySurgicalFills — sakli govdeyi NOT_IMPLEMENTED yerine enjekte", () => { + const SKELETON: GeneratedFile = { + path: "src/users/users.service.ts", + language: "typescript", + surgicalMarkers: 1, + content: `import { Injectable } from "@nestjs/common"; +@Injectable() +export class UsersService { + async getById(id: string): Promise { + // @solarch:surgical id=n1#getById + // Find a user. + // throws: NotFoundException + throw new Error("NOT_IMPLEMENTED: UsersService.getById"); + } +} +`, + }; + + it("sakli govde varsa throw'u govde + @solarch:filled ile degistirir, marker'i korur, girintiyi tutar", () => { + const [out] = applySurgicalFills( + [SKELETON], + [{ nodeId: "n1", member: "getById", body: "const u = await this.repo.findById(id);\nif (!u) throw new NotFoundException();\nreturn u;", filledAt: "2026-06-17T00:00:00Z" }], + ); + expect(out.content).toContain("// @solarch:surgical id=n1#getById"); // marker korundu + expect(out.content).toContain("// throws: NotFoundException"); // bilgi yorumu korundu + expect(out.content).toContain("// @solarch:filled by=ai at=2026-06-17T00:00:00Z"); + expect(out.content).toContain(" const u = await this.repo.findById(id);"); // 4-bosluk girinti + expect(out.content).not.toContain("NOT_IMPLEMENTED"); // iskelet throw'u gitti + }); + + it("sakli govdesi olmayan bolge iskelet kalir (re-fill secsin) — referans degismez", () => { + const [out] = applySurgicalFills([SKELETON], [{ nodeId: "other", member: "x", body: "return 1;", filledAt: "t" }]); + expect(out.content).toContain("NOT_IMPLEMENTED"); + expect(out).toBe(SKELETON); + }); + + it("surgical icermeyen dosyaya dokunmaz", () => { + const plain: GeneratedFile = { path: "x.ts", language: "typescript", surgicalMarkers: 0, content: "export const x = 1;\n" }; + const [out] = applySurgicalFills([plain], [{ nodeId: "n1", member: "getById", body: "return 1;", filledAt: "t" }]); + expect(out).toBe(plain); + }); +}); diff --git a/apps/server/src/codegen/codegen.service.ts b/apps/server/src/codegen/codegen.service.ts new file mode 100644 index 0000000..c7aa1e5 --- /dev/null +++ b/apps/server/src/codegen/codegen.service.ts @@ -0,0 +1,841 @@ +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { NodesRepository } from "../nodes/nodes.repository"; +import { EdgesRepository } from "../edges/edges.repository"; +import { ProjectsRepository } from "../projects/projects.repository"; +import { SurgicalFillRepository, type StoredFill } from "./surgical-fill.repository"; +import { redactNodeSecrets } from "../nodes/secret-redaction"; +import { buildCodeGraph, type CodeGraph, type CodeNode } from "./ir"; +import { projectSimpleView, projectSimpleMermaid, projectSimpleSketchModel, type SystemMapDTO, type SimpleSketchModel } from "./simple-projection"; +import { projectOpenApi } from "./openapi.emitter"; +import type { OpenAPIObject } from "@nestjs/swagger"; +import { getGenerationChat, isGenerationConfigured } from "../ai/providers/llm.factory"; +import { z } from "zod"; +import { SystemMessage, HumanMessage, AIMessage, ToolMessage, type BaseMessage } from "@langchain/core/messages"; +import { lintContracts } from "./contract-lint"; +import { EMITTER_REGISTRY } from "./emitters/nestjs"; +import { emitFeatureModule } from "./emitters/nestjs/module.emitter"; +import { emitScaffoldProject } from "./emitters/nestjs/scaffold.emitter"; +import { emitMigrationRunners } from "./emitters/nestjs/migration-runner.emitter"; +import { emitServiceSpecs } from "./emitters/nestjs/service-spec.emitter"; +import { emitSurgicalPlan } from "./emitters/nestjs/surgical-plan.emitter"; +import { emitSyntheticEntity, tablesNeedingSyntheticEntity } from "./emitters/nestjs/entity-synthesis"; +import { emitSyntheticException, undefinedThrownExceptions } from "./emitters/nestjs/exception-synthesis"; +import { CODEGEN_VERSION } from "./codegen.version"; +import type { + CodegenTarget, + EmitterContext, + GeneratedFile, + GeneratedProject, + SkippedKinds, +} from "./types"; + +/* ──────────────────────────────────────────────────────────────────────── + * codegen.service.ts — orchestrator. + * + * Akis: + * 1) Proje var mi? (yoksa 404 ERR_PROJECT_NOT_FOUND) + * 2) Tum node + edge'leri tek projeye gore cek. + * 3) buildCodeGraph -> cozumlenmis CodeGraph + EmitterContext. + * 4) Her node icin REGISTRY'den emitter calistir: + * - kayitli + supported -> dosya(lar) uret. + * - kayitli + !supported -> stub uret + skippedKinds++. + * - kayitsiz -> emitter yok -> skippedKinds++ (sessizce dusmez). + * 5) Scaffold dosyalarini ekle. + * 6) Determinizm: dosyalari path'e gore sirala, cift path'leri (ilk-kazanir) + * tekillestir, summary doldur. + * ──────────────────────────────────────────────────────────────────────── */ + +@Injectable() +export class CodegenService { + constructor( + private readonly projects: ProjectsRepository, + private readonly nodes: NodesRepository, + private readonly edges: EdgesRepository, + private readonly surgicalFills: SurgicalFillRepository, + ) {} + + private readonly logger = new Logger(CodegenService.name); + + /** Per-project Mermaid cache for the legacy Simple sketch (keyed by deterministic baseline). + * The structured model (the primary path) is persisted in the DB instead — see simpleSketchModel. */ + private readonly sketchCache = new Map(); + + async generate(projectId: string, target: CodegenTarget = "nestjs"): Promise { + if (!(await this.projects.exists(projectId))) { + throw new NotFoundException({ + code: "ERR_PROJECT_NOT_FOUND", + message: `Project '${projectId}' not found.`, + }); + } + + const [storedNodes, storedEdges, fills] = await Promise.all([ + this.nodes.list(projectId), + this.edges.list(projectId), + this.surgicalFills.getAllForProject(projectId), + ]); + + // GUVENLIK (defense-in-depth): nodes.list (repository) redaksiyon YAPMAZ. + // Secret'i IR'a hic sokmamak icin codegen sinirinda redakte et — boylece + // koruma yapisaldir, her emitter'in IsSecret kontrolune bagli degildir. + const redactedNodes = storedNodes.map((n) => ({ + ...n, + properties: redactNodeSecrets(n.type, n.properties), + })); + + const graph = buildCodeGraph(redactedNodes, storedEdges); + const project = this.assemble(graph, target); + // Sakli algoritma govdelerini (bolge-bazinda) iskeletteki NOT_IMPLEMENTED yerine + // geri-enjekte et → re-open/regenerate dolu surumu gosterir, re-fill kaldigi yerden. + if (fills.length > 0) project.files = applySurgicalFills(project.files, fills); + return project; + } + + /** Basit Gorunum (non-dev) projeksiyonu — teknik graf → feature haritasi + + * capability'ler. READ-ONLY, deterministik (Mermaid export'un kardesi); kod + * URETMEZ, AI yok → ucretsiz. Frontend src/features/simple bunu render eder. */ + async simpleView(projectId: string): Promise { + if (!(await this.projects.exists(projectId))) { + throw new NotFoundException({ code: "ERR_PROJECT_NOT_FOUND", message: `Project '${projectId}' not found.` }); + } + const [storedNodes, storedEdges] = await Promise.all([this.nodes.list(projectId), this.edges.list(projectId)]); + // Secret degerleri IR'a hic sokma (defense-in-depth; projeksiyon zaten yapi gosterir). + const redactedNodes = storedNodes.map((n) => ({ ...n, properties: redactNodeSecrets(n.type, n.properties) })); + return projectSimpleView(buildCodeGraph(redactedNodes, storedEdges)); + } + + /** Mermaid for the hand-drawn Simple sketch. Cached per project, keyed by the + * deterministic baseline, so the LLM runs ONLY when the graph actually changes. + * The AI refines a VALID deterministic baseline (so the output is always valid + * Mermaid and covers every part); falls back to that baseline if the LLM is off. */ + async simpleSketch(projectId: string): Promise<{ mermaid: string; source: "ai" | "deterministic" }> { + if (!(await this.projects.exists(projectId))) { + throw new NotFoundException({ code: "ERR_PROJECT_NOT_FOUND", message: `Project '${projectId}' not found.` }); + } + const [storedNodes, storedEdges] = await Promise.all([this.nodes.list(projectId), this.edges.list(projectId)]); + const redactedNodes = storedNodes.map((n) => ({ ...n, properties: redactNodeSecrets(n.type, n.properties) })); + const baseline = projectSimpleMermaid(buildCodeGraph(redactedNodes, storedEdges)); + const key = hashStr(baseline); + + const cached = this.sketchCache.get(projectId); + if (cached && cached.key === key) return { mermaid: cached.mermaid, source: cached.source }; + + let mermaid = baseline; + let source: "ai" | "deterministic" = "deterministic"; + if (isGenerationConfigured()) { + try { + // SURGICAL: if we already have an AI sketch, patch it minimally to match the new + // baseline (keeps existing nodes/labels/order stable → stable layout). Else full refine. + mermaid = cached && cached.source === "ai" + ? await aiPatchMermaid(cached.mermaid, baseline) + : await aiRefineMermaid(baseline); + source = "ai"; + } catch (e) { + this.logger.warn(`Simple sketch (Mermaid) AI refine failed for project ${projectId}: ${(e as Error)?.message ?? e}`); + mermaid = baseline; + source = "deterministic"; + } + } + this.sketchCache.set(projectId, { key, mermaid, source }); + return { mermaid, source }; + } + + /** Structured Simple-View model (Mermaid-free; ELK-laid-out + rough-rendered on the client). + * A tool-calling agent (DeepSeek v4-flash) refines the PRESENTATION of a deterministic + * baseline — friendly names + semantic colors — by calling rename/colorize tools that can + * only touch real ids (structure + kind stay graph-true). Cached per project (the agent runs + * only when the graph changes); falls back to the deterministic model when AI is off. + * `force` bypasses the cache and re-runs the AI refine (the "Regenerate" button) — useful when + * a previous run fell back to deterministic (AI hiccup) and the graph hasn't changed since. */ + async simpleSketchModel( + projectId: string, + stage?: "baseline" | "full", + force = false, + ): Promise<{ model: SimpleSketchModel; source: "ai" | "deterministic"; aiConfigured: boolean }> { + if (!(await this.projects.exists(projectId))) { + throw new NotFoundException({ code: "ERR_PROJECT_NOT_FOUND", message: `Project '${projectId}' not found.` }); + } + const aiConfigured = isGenerationConfigured(); + const [storedNodes, storedEdges] = await Promise.all([this.nodes.list(projectId), this.edges.list(projectId)]); + const redactedNodes = storedNodes.map((n) => ({ ...n, properties: redactNodeSecrets(n.type, n.properties) })); + const baseline = projectSimpleSketchModel(buildCodeGraph(redactedNodes, storedEdges)); + // Two-phase reveal: the client fetches `baseline` first (instant, no AI) to draw the structure, + // then the full (AI-enriched) model — same layout, names/colors settle in. + if (stage === "baseline") return { model: baseline, source: "deterministic", aiConfigured }; + const key = hashStr(JSON.stringify(baseline)); + + // Reuse the PERSISTED AI model while the graph is unchanged (survives restarts). Only AI results + // are stored, so a stored hit is always an "ai" model. `force` (the Regenerate button) skips it. + if (!force) { + const stored = await this.projects.getSimpleSketchModel(projectId); + if (stored && stored.key === key) return { model: stored.model, source: "ai", aiConfigured }; + } + + if (aiConfigured && baseline.nodes.length > 0) { + try { + const model = await aiEnrichSketchModel(baseline); + await this.projects.setSimpleSketchModel(projectId, key, model); // persist the AI result in the DB + return { model, source: "ai", aiConfigured }; + } catch (e) { + // Don't swallow it silently — surface WHY the diagram stayed deterministic (timeout, tool error…). + this.logger.warn(`Simple sketch AI refine failed for project ${projectId}: ${(e as Error)?.message ?? e}`); + } + } + // AI off, no nodes, or AI failed → the deterministic baseline (recomputed instantly; not persisted, + // so the next view naturally retries the AI instead of getting stuck on a fallback). + return { model: baseline, source: "deterministic", aiConfigured }; + } + + /** Interactive API documentation — a deterministic OpenAPI 3.1 doc from the graph, optionally + * enriched by "AI Documentize" (prose + examples only). Mirrors simpleSketchModel: + * - stage "baseline" → instant deterministic doc (no AI, no DB). + * - otherwise → the persisted AI-enriched doc while the graph is unchanged, else the AI runs and + * its result is cached on the Project node; falls back to the deterministic baseline if the AI + * is off or fails. + * The structure (paths/operations/schemas) is always graph-true; the AI only annotates EXISTING + * operations/schemas — it never invents paths. `force` (the "AI Documentize" button) skips the cache. */ + async apiDoc( + projectId: string, + stage?: "baseline" | "full", + force = false, + ): Promise<{ doc: OpenAPIObject; source: "ai" | "deterministic"; aiConfigured: boolean }> { + if (!(await this.projects.exists(projectId))) { + throw new NotFoundException({ code: "ERR_PROJECT_NOT_FOUND", message: `Project '${projectId}' not found.` }); + } + const aiConfigured = isGenerationConfigured(); + const [storedNodes, storedEdges] = await Promise.all([this.nodes.list(projectId), this.edges.list(projectId)]); + // Secret values never enter the IR (defense-in-depth; the doc only describes structure). + const redacted = storedNodes.map((n) => ({ ...n, properties: redactNodeSecrets(n.type, n.properties) })); + const baseline = projectOpenApi(buildCodeGraph(redacted, storedEdges)); + // Two-phase reveal: the client fetches `baseline` first (instant, no AI) to render the structure, + // then the full (AI-enriched) doc — same paths, prose/examples settle in. + if (stage === "baseline") return { doc: baseline, source: "deterministic", aiConfigured }; + const key = hashStr(JSON.stringify(baseline)); + + // Reuse the PERSISTED AI doc while the graph is unchanged (survives restarts). Only AI results are + // stored, so a stored hit is always an "ai" doc. `force` (the Documentize button) skips it. + if (!force) { + const stored = await this.projects.getOpenApiDoc(projectId); + if (stored && stored.key === key) return { doc: stored.doc, source: "ai", aiConfigured }; + } + + if (aiConfigured) { + try { + const doc = await aiDocumentizeOpenApi(baseline); + await this.projects.setOpenApiDoc(projectId, key, doc); // persist the AI result in the DB + return { doc, source: "ai", aiConfigured }; + } catch (e) { + // Don't swallow it silently — surface WHY the doc stayed deterministic (timeout, tool error…). + this.logger.warn(`API documentize failed for project ${projectId}: ${(e as Error)?.message ?? e}`); + } + } + // AI off or AI failed → the deterministic baseline (recomputed instantly; not persisted, so the + // next request naturally retries the AI instead of getting stuck on a fallback). + return { doc: baseline, source: "deterministic", aiConfigured }; + } + + /** Saf montaj — DB'siz test edilebilir (in-memory CodeGraph al, proje uret). */ + assemble(graph: CodeGraph, target: CodegenTarget = "nestjs"): GeneratedProject { + const ctx: EmitterContext = { graph, target }; + const skippedKinds: SkippedKinds = {}; + const collected: GeneratedFile[] = []; + + // graph.nodes zaten isme gore sirali -> emit sirasi deterministik. + // Node emitter'lar feature dosyalarini proje KOKUNE goreli uretir + // (or. "auth/auth.service.ts"); montaj burada TypeScript feature + // dosyalarina "src/" onekini ekler ki scaffold (src/main.ts, src/app.module.ts) + // + tsconfig "include": ["src/**/*"] ile TEK agac altinda toplansin. SQL + // migration'lari ("migrations/...") KOKTE kalir (derlenmez; siraya tabidir). + for (const node of graph.nodes) { + // Kapsam-disi (FrontendApp/UIComponent/View): bir backend'de frontend + // bilesenin yeri yok -> DOSYA URETME, yalniz skippedKinds'e say. + if (graph.isExcluded(node)) { + bump(skippedKinds, node.kindOf()); + continue; + } + // Module node: per-node NOT, FEATURE basina sentezlenir (asagida). + // Feature SEED'i olarak ir.ts'te kullanilir; ayri dosya uretmez. + if (node.kindOf() === "Module") continue; + + const entry = EMITTER_REGISTRY[node.kindOf()]; + if (!entry) { + bump(skippedKinds, node.kindOf()); + continue; + } + // FAULT-ISOLATION (M5): adi WITHOUT node bozuktur (name-property string degil + // ya da bos — ir.toCodeNode "" verir). Gecerli/sema-dogrulanmis her node'un + // adi zorunludur; bos ad = bozuk girdi. Gecerli bir sinif/dosya adi turetilemez + // -> dosya URETME, skippedKinds'e say, siradakine gec (sessizce DUSME). + if (node.name.trim().length === 0) { + bump(skippedKinds, node.kindOf()); + continue; + } + if (!entry.supported) bump(skippedKinds, node.kindOf()); + // FAULT-ISOLATION (M5): tek bozuk node (or. emitter undefined alana patlar) + // TUM codegen'i dusurmemeli. Bu node'un emit'ini izole et — patlarsa onu + // skippedKinds'e say ve siradakine gec; geri kalan graph uretilmeye devam + // eder. Hatayi yutmak NOT, tek node'u atlamak: deterministik + saf kalir + // (girdi sabitse hangi node'un patladigi da sabittir). + let emitted: GeneratedFile[]; + try { + emitted = entry.emit(node, ctx); + } catch (e) { + if (process.env.SOLARCH_DEBUG_EMIT) console.error(`EMIT FAIL ${node.kindOf()} "${node.name}": ${(e as Error).message}\n${(e as Error).stack?.split("\n").slice(1, 4).join("\n")}`); + // Bozuk node -> dosya uretme, skippedKinds'e say (sessizce dusmez). + // supported=false zaten yukarida sayildi -> DOUBLE sayma. Yalniz + // supported (gercek emitter) patlarsa burada say. + if (entry.supported) bump(skippedKinds, node.kindOf()); + continue; + } + for (const f of emitted) { + // node-emitter ciktisi -> dosyayi URETEN node.id ile etiketle (nodeFiles). + const tagged = { ...f, nodeId: node.id }; + collected.push(tagged.language === "typescript" ? { ...tagged, path: `src/${tagged.path}` } : tagged); + } + } + + // ── ENTITY SENTEZI (Table-only graph BOOT garantisi) ──────────────────── + // Model'i WITHOUT ama bir Repository tarafindan referans edilen her Table + // icin TypeORM @Entity sinifi sentezlenir. Boylece @InjectRepository(Entity), + // Repository ve TypeOrmModule.forFeature([Entity]) AYNI sinifa baglanir + // -> NestJS DI bootta repository provider'ini cozebilir, uygulama ACILIR. + for (const table of tablesNeedingSyntheticEntity(graph)) { + for (const f of emitSyntheticEntity(table, ctx)) { + collected.push({ ...f, path: `src/${f.path}` }); + } + } + + // ── EXCEPTION SENTEZI (bildirilmis-ama-tanimsiz Throws DERLEME garantisi) ── + // Bir Service metodu Throws=[X] bildirir ama X icin Exception node'u yoksa, + // service.emitter X'i surgical marker'a yazar + sentetik dosyadan import eder; + // fill kontrati (checkContract) X'i firlatmaya zorlar. X'in sinifi burada + // uretilmezse `throw new X` import'suz/tanimsiz kalir → TS2304. Sentezle. + for (const exName of undefinedThrownExceptions(graph)) { + const f = emitSyntheticException(exName); + collected.push({ ...f, path: `src/${f.path}` }); + } + + // ── FEATURE-MODULE SENTEZI (mimari-farkindalik) ───────────────────────── + // Graph'ta Module node OLMASA bile her cikarilmis feature icin bir + // .module.ts uretilir; app.module bunlari import eder -> DI tam, + // repository'ler kayitli, uygulama BOOT BOOTS. features() zaten slug'a sirali. + for (const feature of graph.features()) { + for (const f of emitFeatureModule(feature, ctx)) { + collected.push({ ...f, path: `src/${f.path}` }); + } + } + + // ── COMMON-MODULE SENTEZI (feature-bagsiz altyapi) ────────────────────── + // "common/"a dusen feature-bagsiz altyapi (MessageQueue/EventHandler/Cache/ + // ... ve paylasimli @Controller/APIGateway'ler) bir feature module almaz -> + // BullModule.registerQueue HIC cagrilmaz, provider orphan kalirdi. CommonModule + // bunlari toplar + wiring'ini yapar; AppModule import eder (buildAppModule). + const commonFeature = graph.commonFeature(); + if (commonFeature) { + for (const f of emitFeatureModule(commonFeature, ctx)) { + collected.push({ ...f, path: `src/${f.path}` }); + } + } + + // ── SERVICE TEST ISKELETI SENTEZI (H6) ────────────────────────────────── + // Her Service icin yaninda bir .service.spec.ts iskeleti (Test.create + // TestingModule + DI mock'lari). Feature TS dosyalari gibi "src/" altina alinir. + for (const f of emitServiceSpecs(ctx)) { + collected.push({ ...f, path: `src/${f.path}` }); + } + + // Scaffold (node'dan bagimsiz) proje-genel dosyalar (graph-farkinda). + // Bunlar zaten dogru kokte ("src/" TS dosyalari + kok package.json/tsconfig/...). + collected.push(...emitScaffoldProject(ctx)); + + // ── MIGRATION RUNNER SENTEZI (H5) ──────────────────────────────────────── + // table/view emitter'lari okunakli `migrations/NNN_create_.sql` uretir ama + // bunlar TypeORM CLI'ca calistirilamaz. Toplanan SQL'leri CALISTIRILABILIR + // `src/migrations/NNN-.ts` (MigrationInterface) siniflarina cevir; + // data-source.ts glob'u bunlara bakar -> `npm run db:migrate` semayi uygular. + // SQL dosyalari NNN'e gore sirali verilir (path sirasi deterministik). + const sqlMigrations = collected + .filter((f) => f.language === "sql" && /^migrations\/\d+_create_.+\.sql$/.test(f.path)) + .map((f) => ({ path: f.path, content: f.content })) + .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + collected.push(...emitMigrationRunners(sqlMigrations)); + + // ── SURGICAL PLAN SENTEZI (SURGICAL_PLAN.md) ──────────────────────────── + // TUM dosyalar (scaffold + feature + migration dahil) deduped+sorted hâle + // geldikten AFTER uretilir ki plan, uretilen her .ts dosyasindaki + // "@solarch:surgical" marker'larini GOREBILSIN. Sonra dosyaya eklenir ve + // liste yeniden dedupe/sort edilir (SURGICAL_PLAN.md dogru yere otursun). + // ONEMLI: MD'nin KENDI marker'i NONETUR (plan METNIdir, surgicalMarkers:0) + // -> surgicalMarkerCount mantigi bozulmaz. + const assembled = dedupeAndSort(collected); + const surgicalPlan = emitSurgicalPlan(assembled, graph); + const files = dedupeAndSort([...assembled, surgicalPlan]); + const surgicalMarkerCount = files.reduce((sum, f) => sum + f.surgicalMarkers, 0); + // node.id -> uretilen dosya yollari. NIHAI (deduped+sorted) dosyalardan kurulur + // -> path'ler montaj sonrasi gercek yollardir. Yalniz nodeId tasiyan dosyalar. + const nodeFiles = buildNodeFiles(files); + + return { + target, + files, + nodeFiles, + // M4: graph'ta tespit edilen yapisal uyarilar (kirilan dongusel module + // import'lari vb.) + diyagram-ani kontrat denetimi (contract-lint: govde-alan + // write endpoint'i input DTO'su olmadan vb.) ciktiya tasinir — aksi halde + // sessizce kaybolurdu. Ikisi de deterministik + sirali. + warnings: [...graph.warnings(), ...lintContracts(graph)], + summary: { + version: CODEGEN_VERSION, + fileCount: files.length, + nodeCount: graph.nodes.length, + surgicalMarkerCount, + skippedKinds: sortRecord(skippedKinds), + }, + }; + } +} + +const SURGICAL_MARKER_RE = /\/\/\s*@solarch:surgical\s+id=([^\s#]+)#(\S+)/; +const NOT_IMPLEMENTED_RE = /^(\s*)throw new Error\("NOT_IMPLEMENTED:/; + +/** Sakli algoritma govdelerini iskeletteki NOT_IMPLEMENTED satirinin yerine enjekte eder + * (deterministik, string-bazli — ts-morph gerektirmez). Her surgical marker + * (`// @solarch:surgical id=nodeId#member`) icin sakli govde varsa: marker + bilgi + * yorumlarini korur, NOT_IMPLEMENTED throw satirini `// @solarch:filled` imzasi + govde + * (satir girintisi marker blogununkiyle eslenir) ile degistirir. Sakli govdesi olmayan + * bolgeler iskelet kalir → re-fill onlari secer (kaldigi yerden devam). */ +export function applySurgicalFills(files: GeneratedFile[], fills: StoredFill[]): GeneratedFile[] { + const byKey = new Map(); + for (const f of fills) byKey.set(`${f.nodeId}#${f.member}`, f); + + return files.map((file) => { + if (file.language !== "typescript" || !file.content.includes("@solarch:surgical")) return file; + const lines = file.content.split("\n"); + const out: string[] = []; + let changed = false; + for (let i = 0; i < lines.length; i++) { + const m = SURGICAL_MARKER_RE.exec(lines[i]!); + const fill = m ? byKey.get(`${m[1]}#${m[2]}`) : undefined; + if (!fill) { + out.push(lines[i]!); + continue; + } + // marker satiri + onu izleyen yorum (// …) satirlarini koru. + out.push(lines[i]!); + let j = i + 1; + while (j < lines.length && /^\s*\/\//.test(lines[j]!)) { + out.push(lines[j]!); + j++; + } + // Simdi lines[j] NOT_IMPLEMENTED throw olmali; degilse (zaten dolu) dokunma. + const thr = NOT_IMPLEMENTED_RE.exec(lines[j] ?? ""); + if (!thr) { + i = j - 1; // yorum satirlarini ciktiladik; dongu j'den devam etsin + continue; + } + const indent = thr[1] ?? ""; + out.push(`${indent}// @solarch:filled by=ai at=${fill.filledAt}`); + out.push(""); + for (const bl of fill.body.split("\n")) out.push(bl.length > 0 ? `${indent}${bl}` : ""); + changed = true; + i = j; // throw satirini atla + } + return changed ? { ...file, content: out.join("\n") } : file; + }); +} + +/** Nihai dosyalardan node.id -> path[] haritasi kurar. Yalniz nodeId tasiyan + * (node-emitter) dosyalar dahil; anahtarlar + her node'un path listesi sirali. */ +function buildNodeFiles(files: GeneratedFile[]): Record { + const map = new Map(); + for (const f of files) { + if (!f.nodeId) continue; + const arr = map.get(f.nodeId); + if (arr) arr.push(f.path); + else map.set(f.nodeId, [f.path]); + } + const out: Record = {}; + for (const id of [...map.keys()].sort()) { + out[id] = [...map.get(id)!].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + } + return out; +} + +function bump(rec: SkippedKinds, key: string): void { + rec[key] = (rec[key] ?? 0) + 1; +} + +/** Path'e gore sirala + cift path'leri tekillestir (ilk-kazanir). */ +function dedupeAndSort(files: GeneratedFile[]): GeneratedFile[] { + const byPath = new Map(); + for (const f of files) if (!byPath.has(f.path)) byPath.set(f.path, f); + return [...byPath.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +} + +/** Record'u key'e gore sirali yeniden kurar (deterministik JSON ciktisi). */ +function sortRecord(rec: SkippedKinds): SkippedKinds { + const out: SkippedKinds = {}; + for (const k of Object.keys(rec).sort()) out[k] = rec[k]; + return out; +} + +// CodeNode tipini disa-bagli tuketicilere kapatma — sadece tip referansi. +export type { CodeNode }; + +/** FNV-1a → short stable key (cache invalidation on graph change). */ +function hashStr(s: string): string { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } + return (h >>> 0).toString(36); +} + +/** A complete, rich, non-dev example placed BEFORE the rules (visual primacy): the LLM + * anchors on a concrete target shape — a small DFD with labeled data-flows — instead of + * guessing. Small, consistent shape vocabulary; every arrow carries a plain verb. */ +const SKETCH_EXAMPLE = [ + "flowchart TD", + ' orders["Orders"]', + ' orders --> orders__gate{"Signed in?"}', + ' orders__gate --> orders__a0["Place an order"]', + ' orders__gate --> orders__a1["See your orders"]', + ' orders__a0 -->|Saves| orders__d0[("Orders")]', + ' orders__a1 -->|Reads| orders__d0', + ' orders -->|Uses| orders__x0[/"Stripe"/]', + ' payments["Payments"]', + " orders -->|Needs| payments", +].join("\n"); + +/** Refine a valid deterministic Mermaid into a friendlier, RICHER non-dev one (DeepSeek + * v4-pro). Research-shaped prompt: a full worked example first, then a small fixed shape + * set + labeled arrows, so the output reads like a real data-flow story (not a bare list) + * while staying truthful to the input. */ +async function aiRefineMermaid(baseline: string): Promise { + const llm = getGenerationChat({ model: "deepseek-v4-pro" }); + const sys = + "You turn a software system into a friendly Mermaid flowchart for NON-DEVELOPERS. " + + "It must read like a real data-flow story a person can follow, not a bare list. " + + 'Output STRICTLY JSON: {"mermaid":""} and NOTHING else.\n\n' + + "Match the shape of this example exactly (small, consistent visual vocabulary):\n" + + SKETCH_EXAMPLE + + "\n\nRules:\n" + + '1. Start with "flowchart TD". Do NOT use subgraphs.\n' + + "2. Keep EVERY feature, operation, data store, outside service and cross-feature link from the input — never drop parts, and never invent parts that are not there.\n" + + '3. Use ONLY these four shapes: ["label"] = an action/step, {"label"} = a yes/no decision, [("label")] = stored data, [/"label"/] = an outside service. No other shapes.\n' + + "4. LABEL every relationship arrow with one short plain verb: Saves, Reads, Uses, Needs, Works with, or Notifies.\n" + + "5. Use plain human language for labels (no code, ids, or jargon) and keep them short.\n" + + "6. Keep it legible: do not add extra crossing arrows and never repeat the same check.\n" + + "Output ONLY the JSON object."; + const user = + "Rewrite this structure into a clearer, friendlier data-flow flowchart for a non-technical reader. " + + "Keep it valid Mermaid, keep every part, and keep the data stores and the labeled arrows.\n\n" + + baseline; + const res = await llm.invoke([ + { role: "system", content: sys }, + { role: "user", content: user }, + ]); + const txt = typeof res.content === "string" ? res.content : JSON.stringify(res.content); + const parsed = JSON.parse(txt) as { mermaid?: string }; + const m = String(parsed.mermaid ?? "").trim(); + if (!/^(flowchart|graph)\b/.test(m)) throw new Error("invalid mermaid from AI"); + return m; +} + +/** SURGICAL update: minimally edit an existing friendly Mermaid to match a new baseline + * (add/remove only the changed parts; keep everything else identical → stable layout). */ +async function aiPatchMermaid(previous: string, newBaseline: string): Promise { + const llm = getGenerationChat({ model: "deepseek-v4-pro" }); + const sys = + "You SURGICALLY update an existing friendly Mermaid flowchart to match a new structure, " + + "changing as LITTLE as possible. Keep EVERY existing node id, label, shape and order " + + "IDENTICAL unless the new structure removed it; only ADD the genuinely new parts and REMOVE " + + "the deleted ones, so the diagram stays visually stable. Preserve the data stores ([(\"...\")]) " + + "and the labeled arrows (Saves / Reads / Uses / Needs / Notifies). Do NOT use subgraphs. " + + 'Start with "flowchart TD". Output STRICTLY JSON: {"mermaid":""} and nothing else.'; + const user = + `PREVIOUS friendly Mermaid (keep as stable as possible):\n${previous}\n\n` + + `NEW structure to match now (deterministic baseline):\n${newBaseline}\n\n` + + `Return the minimally-updated friendly Mermaid.`; + const res = await llm.invoke([ + { role: "system", content: sys }, + { role: "user", content: user }, + ]); + const txt = typeof res.content === "string" ? res.content : JSON.stringify(res.content); + const parsed = JSON.parse(txt) as { mermaid?: string }; + const m = String(parsed.mermaid ?? "").trim(); + if (!/^(flowchart|graph)\b/.test(m)) throw new Error("invalid mermaid from AI patch"); + return m; +} + +/* ── Tool-calling enrichment of the structured sketch model (DeepSeek v4-flash) ─────────── */ + +const RenameSketchArgs = z.object({ + id: z.string().describe("the id of the part to rename (must be one of the given part ids)"), + name: z.string().describe("a short, plain, human name a non-developer understands (no code, ids, or jargon; 1-4 words)"), +}); + +/** Refine a deterministic SimpleSketchModel's PRESENTATION via tool calls — RENAME ONLY. + * Colors are deterministic (the projector gives each group a distinct palette hue). The AI is NOT + * allowed to color: in practice it washes EVERY group into one hue, which destroys the grouping. + * Rename only touches existing ids; structure, kind, color, groups and connections stay graph-true. */ +async function aiEnrichSketchModel(model: SimpleSketchModel): Promise { + const llm = getGenerationChat({ toolCalling: true }); // default model = deepseek-v4-flash (tool calling OK) + const withTools = llm.bindTools!([ + { name: "rename", description: "Rename a part to a short, plain, non-developer name (e.g. 'MessageController' → 'Messages'). Only ids that exist. Returns { ok }.", schema: RenameSketchArgs }, + ]); + + const nodes = new Map(model.nodes.map((n) => [n.id, { ...n }])); + + const inventory = JSON.stringify({ + groups: model.groups.map((g) => ({ id: g.id, name: g.name })), + parts: model.nodes.map((n) => ({ id: n.id, kind: n.kind, name: n.name })), + connections: model.edges.map((e) => ({ from: e.from, to: e.to, label: e.label })), + }); + const sys = + "You make a software system's diagram friendly for NON-DEVELOPERS (business people). You are given its " + + "PARTS (id, kind, current name), GROUPS, and CONNECTIONS. RENAME parts to short, plain, human names — drop " + + "code suffixes and ids ('MessageController' → 'Messages', 'UserRepository' → 'Users'); leave already-friendly " + + "names. Colors are handled automatically (each group already has its own distinct color) — do NOT try to color " + + "anything. Only reference ids that exist. NEVER invent parts or change connections. Call rename, then stop."; + const messages: BaseMessage[] = [ + new SystemMessage(sys), + new HumanMessage("Diagram:\n" + inventory + "\n\nRename the parts to friendly names, then stop."), + ]; + + const MAX_TURNS = 8; + for (let turn = 0; turn < MAX_TURNS; turn++) { + const ai = (await withTools.invoke(messages)) as AIMessage; + const calls = (ai.tool_calls ?? []) as Array<{ id?: string; name: string; args: Record }>; + if (calls.length === 0) break; + messages.push(ai); + for (const call of calls) { + let result: { ok: boolean; message?: string }; + try { + if (call.name === "rename") { + const a = RenameSketchArgs.parse(call.args); + const n = nodes.get(a.id); + if (!n) result = { ok: false, message: `no part with id '${a.id}'` }; + else { n.name = a.name.trim().slice(0, 60); result = { ok: true }; } + } else result = { ok: false, message: `unknown tool '${call.name}'` }; + } catch (e) { + result = { ok: false, message: String((e as Error).message).slice(0, 140) }; + } + messages.push(new ToolMessage({ content: JSON.stringify(result), tool_call_id: call.id ?? call.name })); + } + } + // groups untouched → deterministic distinct palette colors survive. + return { nodes: [...nodes.values()], edges: model.edges, groups: model.groups }; +} + +/* ── AI Documentize: grounded tool-calling enrichment of the OpenAPI doc ────────────────── + * The structure (paths/operations/schemas) is verified and graph-true. The agent only adds + * PROSE and EXAMPLES on EXISTING operations/schemas — it can never invent a path, operation, or + * schema. Every tool funnels through an apply-helper that looks its target up by operationId / + * schema name; a miss returns { ok:false } as a ToolMessage so the model self-corrects instead of + * fabricating. Mirrors aiEnrichSketchModel (getGenerationChat({toolCalling}) → bindTools → loop). */ + +/** Find an operation object (the method entry under a path) by operationId. Returns the live + * reference so callers mutate the doc in place; null when no operation carries that id. */ +function findOperation(doc: OpenAPIObject, operationId: string): Record | null { + const paths = (doc.paths ?? {}) as Record>; + for (const path of Object.keys(paths)) { + const item = paths[path] ?? {}; + for (const method of Object.keys(item)) { + const op = item[method] as Record | undefined; + if (op && typeof op === "object" && op.operationId === operationId) return op; + } + } + return null; +} + +const DescribeOperationArgs = z.object({ + operationId: z.string().describe("the operationId to annotate (must be one of the listed operationIds)"), + summary: z.string().optional().describe("a short one-line summary (a handful of words)"), + description: z.string().optional().describe("1-3 plain sentences describing what the operation does"), +}); +const ExampleResponseArgs = z.object({ + operationId: z.string().describe("the operationId whose response to give an example for"), + status: z.union([z.string(), z.number()]).describe("the HTTP status code of an EXISTING response (e.g. 200, 201)"), + exampleJson: z.string().describe("a realistic example response body, as a JSON string"), +}); +const DescribeSchemaArgs = z.object({ + schema: z.string().describe("the component schema name to annotate (must be one of the listed schemas)"), + description: z.string().describe("1-2 plain sentences describing what the schema represents"), +}); +const DescribeFieldArgs = z.object({ + schema: z.string().describe("the component schema name that owns the field"), + field: z.string().describe("the field name to annotate (must exist on that schema)"), + description: z.string().describe("a short, specific Markdown description of the field (what it is, constraints, example)"), +}); +const DescribeApiArgs = z.object({ + description: z.string().describe("a Markdown OVERVIEW of the whole API — what it does, the main resources, how auth works, and a short getting-started (2-5 short paragraphs / lists)"), +}); + +/** Set summary/description on an EXISTING operation. No-op (ok:false) for an unknown operationId — + * the agent can annotate but never invents a path or operation. Exported for deterministic tests. */ +export function applyDescribeOperation( + doc: OpenAPIObject, + args: { operationId: string; summary?: string; description?: string }, +): { ok: boolean } { + const op = findOperation(doc, args.operationId); + if (!op) return { ok: false }; + if (args.summary) op.summary = args.summary.trim().slice(0, 200); + if (args.description) op.description = args.description.trim().slice(0, 4000); + return { ok: true }; +} + +/** Set the API-level overview (info.description, Markdown) — the Docs landing. Always applies. */ +export function applyDescribeApi(doc: OpenAPIObject, args: { description: string }): { ok: boolean } { + if (!doc.info) doc.info = { title: "API", version: "1.0.0" } as OpenAPIObject["info"]; + doc.info.description = args.description.trim().slice(0, 6000); + return { ok: true }; +} + +/** Attach an example to an EXISTING response (matched by status) of an existing operation. Invalid + * JSON or an unknown operation/status is a no-op (ok:false) — examples never create new responses. */ +export function applyExampleResponse( + doc: OpenAPIObject, + args: { operationId: string; status: string | number; exampleJson: string }, +): { ok: boolean } { + const op = findOperation(doc, args.operationId); + if (!op) return { ok: false }; + const responses = (op.responses ?? {}) as Record>; + const resp = responses[String(args.status)]; + if (!resp) return { ok: false }; + let parsed: unknown; + try { + parsed = JSON.parse(args.exampleJson); + } catch { + return { ok: false }; + } + const content = (resp.content ?? (resp.content = {})) as Record>; + const json = (content["application/json"] ?? (content["application/json"] = {})) as Record; + json.example = parsed; + return { ok: true }; +} + +/** Set a description on an EXISTING component schema; unknown schema name is a no-op (ok:false). */ +export function applyDescribeSchema( + doc: OpenAPIObject, + args: { schema: string; description: string }, +): { ok: boolean } { + const schemas = (doc.components?.schemas ?? {}) as Record>; + const s = schemas[args.schema]; + if (!s) return { ok: false }; + s.description = args.description.trim().slice(0, 2000); + return { ok: true }; +} + +/** Set a description on an EXISTING field of an existing schema; unknown schema/field is a no-op. */ +export function applyDescribeField( + doc: OpenAPIObject, + args: { schema: string; field: string; description: string }, +): { ok: boolean } { + const schemas = (doc.components?.schemas ?? {}) as Record> }>; + const prop = schemas[args.schema]?.properties?.[args.field]; + if (!prop) return { ok: false }; + prop.description = args.description.trim().slice(0, 1000); + return { ok: true }; +} + +/** Enrich a deterministic OpenAPI doc with prose + examples via grounded tool calls. The agent is + * given an inventory of EXISTING operations and schemas and may only annotate those (every tool + * goes through a grounded apply-helper). Structure stays graph-true; the doc is cloned so the + * deterministic baseline is never mutated. */ +async function aiDocumentizeOpenApi(doc: OpenAPIObject): Promise { + const llm = getGenerationChat({ toolCalling: true }); // default model = deepseek-v4-flash (tool calling OK) + const withTools = llm.bindTools!([ + { name: "describeApi", description: "Set a Markdown OVERVIEW of the whole API (info.description) — the documentation landing. Call once. Returns { ok }.", schema: DescribeApiArgs }, + { name: "describeOperation", description: "Set a summary and/or a rich Markdown description on an EXISTING operation (by operationId). Only listed operationIds. Returns { ok }.", schema: DescribeOperationArgs }, + { name: "exampleResponse", description: "Attach a realistic example body to an EXISTING response (by operationId + status). Only listed operations/statuses. Returns { ok }.", schema: ExampleResponseArgs }, + { name: "describeSchema", description: "Set a Markdown description on an EXISTING component schema (by name). Only listed schemas. Returns { ok }.", schema: DescribeSchemaArgs }, + { name: "describeField", description: "Set a description on an EXISTING field of a schema (by schema + field). Only listed fields. Returns { ok }.", schema: DescribeFieldArgs }, + ]); + + // Clone so the agent annotates a copy — the deterministic baseline is never mutated in place. + const out = JSON.parse(JSON.stringify(doc)) as OpenAPIObject; + + // Inventory of the EXISTING surface the agent is allowed to annotate (operationIds, statuses, schemas, fields). + const operations: { operationId: string; method: string; path: string; summary?: string; statuses: string[] }[] = []; + const paths = (out.paths ?? {}) as Record>>; + for (const path of Object.keys(paths)) { + const item = paths[path] ?? {}; + for (const method of Object.keys(item)) { + const op = item[method]; + if (op && typeof op === "object" && typeof op.operationId === "string") { + operations.push({ + operationId: op.operationId, + method: method.toUpperCase(), + path, + summary: typeof op.summary === "string" ? op.summary : undefined, + statuses: Object.keys((op.responses ?? {}) as Record), + }); + } + } + } + const schemaObj = (out.components?.schemas ?? {}) as Record }>; + const schemas = Object.keys(schemaObj).map((name) => ({ schema: name, fields: Object.keys(schemaObj[name]?.properties ?? {}) })); + if (operations.length === 0 && schemas.length === 0) return out; + + const inventory = JSON.stringify({ operations, schemas }); + const sys = + "You write developer-friendly documentation for a REST API, in GitHub-flavored MARKDOWN. You are given " + + "its EXISTING operations (operationId, method, path, response statuses) and component schemas (name + " + + "fields). Do ALL of: (1) call describeApi ONCE with a Markdown OVERVIEW of the whole API — what it does, " + + "the main resources, how auth works, and a short getting-started; (2) give each operation a concise summary " + + "AND a richer Markdown description (what it does, when to use it, key behaviors/errors) using paragraphs, " + + "bullet lists, and `code` spans where helpful; (3) attach a realistic example body to each operation's main " + + "success response; (4) write a clear, specific description for each schema and each field. Write real, " + + "specific prose — not filler like 'This endpoint does X.'. ONLY reference operationIds, statuses, schema " + + "names and field names from the inventory — a tool targeting something not in the inventory returns " + + "{ ok:false }. NEVER invent operations, paths, schemas or fields. No secrets or real credentials in " + + "examples. Call the tools, then stop."; + const messages: BaseMessage[] = [ + new SystemMessage(sys), + new HumanMessage("API surface:\n" + inventory + "\n\nDocument the operations and schemas, then stop."), + ]; + + const MAX_TURNS = 12; + for (let turn = 0; turn < MAX_TURNS; turn++) { + const ai = (await withTools.invoke(messages)) as AIMessage; + const calls = (ai.tool_calls ?? []) as Array<{ id?: string; name: string; args: Record }>; + if (calls.length === 0) break; + messages.push(ai); + for (const call of calls) { + let result: { ok: boolean; message?: string }; + try { + switch (call.name) { + case "describeApi": { + const a = DescribeApiArgs.parse(call.args); + result = applyDescribeApi(out, a).ok ? { ok: true } : { ok: false }; + break; + } + case "describeOperation": { + const a = DescribeOperationArgs.parse(call.args); + result = applyDescribeOperation(out, a).ok ? { ok: true } : { ok: false, message: `no operation '${a.operationId}'` }; + break; + } + case "exampleResponse": { + const a = ExampleResponseArgs.parse(call.args); + result = applyExampleResponse(out, a).ok ? { ok: true } : { ok: false, message: `no response '${a.status}' on '${a.operationId}' (or invalid JSON)` }; + break; + } + case "describeSchema": { + const a = DescribeSchemaArgs.parse(call.args); + result = applyDescribeSchema(out, a).ok ? { ok: true } : { ok: false, message: `no schema '${a.schema}'` }; + break; + } + case "describeField": { + const a = DescribeFieldArgs.parse(call.args); + result = applyDescribeField(out, a).ok ? { ok: true } : { ok: false, message: `no field '${a.field}' on '${a.schema}'` }; + break; + } + default: + result = { ok: false, message: `unknown tool '${call.name}'` }; + } + } catch (e) { + result = { ok: false, message: String((e as Error).message).slice(0, 140) }; + } + messages.push(new ToolMessage({ content: JSON.stringify(result), tool_call_id: call.id ?? call.name })); + } + } + return out; +} diff --git a/apps/server/src/codegen/codegen.version.ts b/apps/server/src/codegen/codegen.version.ts new file mode 100644 index 0000000..d0c0d4e --- /dev/null +++ b/apps/server/src/codegen/codegen.version.ts @@ -0,0 +1,45 @@ +/* ──────────────────────────────────────────────────────────────────────── + * codegen.version.ts — SINGLE SOURCE for Constructor version. + * + * CODEGEN_VERSION increments +1 on each major (output/scaffold-changing) Constructor + * improvement. Integer (not semver) — answers "which generation of Constructor + * produced the code the user has". + * + * v1 -> first major improvement when this file was added (legacy scaffold). + * v2 -> Architecture-aware feature layout + feature-module synthesis + entity synthesis + * + migration runner + service-spec scaffold. + * v3 -> v2 + SURGICAL_PLAN.md added (scans ALL markers after assembly, + * English prompt pasteable to AI). + * v4 -> Phase 4 DEEP-VALIDATION: real compile with realistic graphs + + * migration/boot verified. current-user.decorator synthesis (RequiresAuth / + * login endpoint resolves AuthUser/AuthResponse imports) + @OneToMany + * relations use definite-assignment "!" instead of array initializer (= []) + * (TypeORM InitializedRelationError fix -> migration/boot now passes). + * v5 -> previous (CURRENT at time). QUICK-WIN #2 + #7. + * #2 LIST-RETURN ALIGNMENT: when service method has raw ReturnType="XDto[]" + filled + * ReturnDtoRef, now PRESERVES raw Type's array/wrapper suffix + * (applyTypeWrapper) -> service returns Promise, SAME signature as + * related controller (previously fell back to bare XDto when DtoRef set -> mismatch). + * #7 CROSS-FEATURE INFRA SINGLETON: Cache/ExternalService injected from multiple + * features (e.g. PaymentGateway) now lives in ONE OWNER feature's + * providers/exports; other injecting features import owner's module + * (dependsOn) -> ONE instance at boot (previously separate provider per injecting + * module -> multiple instances -> broken singleton). + * EXTRA (from boot validation): repository CustomQuery param/return types with + * generic SQL ENUM/JSON (no EnumRef) now normalize to string/Record + * (scalarTsType) -> bare `ENUM`/`JSON` TS2304 fixed. + * v6 -> current (CURRENT). SELF-DOCUMENTING APP: controllers carry @ApiTags/ + * @ApiOperation/@ApiResponse/@ApiBearerAuth, DTO fields @ApiProperty; + * main.ts sets up OpenAPI doc via @nestjs/swagger and interactive Scalar reference + * at /docs via @scalar/nestjs-api-reference. Separate DOCS_CORS_ORIGIN + * flag allows Scalar "try it" origin WITHOUT loosening prod CORS_ORIGIN. + * package.json adds @nestjs/swagger + @scalar/nestjs-api-reference. + * + * Stamping: after each successful POST /projects/:id/codegen, project node gets + * codegenVersion = CODEGEN_VERSION. GET .../codegen/status compares stamp to + * CURRENT (updateAvailable). GeneratedProject.summary.version is also + * this constant -> every output tagged with its generation. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Current Constructor version (CURRENT). Single source — do not hardcode elsewhere. */ +export const CODEGEN_VERSION = 6; diff --git a/apps/server/src/codegen/contract-lint.spec.ts b/apps/server/src/codegen/contract-lint.spec.ts new file mode 100644 index 0000000..e47da10 --- /dev/null +++ b/apps/server/src/codegen/contract-lint.spec.ts @@ -0,0 +1,236 @@ +import { describe, it, expect } from "vitest"; +import { lintContracts } from "./contract-lint"; +import { buildCodeGraph } from "./ir"; +import type { StoredNode } from "../nodes/nodes.repository"; + +/* ── Fixture yardimcisi ─────────────────────────────────────────────────── */ +function node(type: StoredNode["type"], id: string, properties: Record): StoredNode { + return { + id, + type, + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +const ep = (over: Record) => ({ + HttpMethod: "POST", + Route: "/", + RequiresAuth: false, + RequiredRoles: [], + PathParams: [], + QueryParams: [], + StatusCodes: [], + MiddlewareRefs: [], + ...over, +}); + +const CTRL = "c0000000-0000-4000-8000-000000000001"; + +describe("lintContracts", () => { + it("RequestDTORef'siz write endpoint (POST) -> uyari (controller + metot + route)", () => { + const ctrl = node("Controller", CTRL, { + ControllerName: "CategoryController", + Description: "kategori", + BaseRoute: "categories", + Endpoints: [ep({ HttpMethod: "POST", Route: "/" })], + }); + const warnings = lintContracts(buildCodeGraph([ctrl], [])); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("CategoryController"); + expect(warnings[0]).toContain("POST"); + expect(warnings[0]).toMatch(/RequestDTORef|request body|input DTO/i); + }); + + it("PUT ve PATCH de govde-alan write -> RequestDTORef'siz uyari", () => { + const ctrl = node("Controller", CTRL, { + ControllerName: "OrderController", + Description: "order", + BaseRoute: "orders", + Endpoints: [ + // PathParam verilir -> route-param kurali tetiklenmez; yalniz RequestDTORef'siz body kurali. + ep({ HttpMethod: "PUT", Route: ":id", PathParams: [{ Name: "id", Type: "string" }] }), + ep({ HttpMethod: "PATCH", Route: ":id/status", PathParams: [{ Name: "id", Type: "string" }] }), + ], + }); + expect(lintContracts(buildCodeGraph([ctrl], []))).toHaveLength(2); + }); + + it("RequestDTORef OLAN write -> uyari yok; GET/DELETE (govdesiz) -> uyari yok", () => { + const ctrl = node("Controller", CTRL, { + ControllerName: "ProductController", + Description: "urun", + BaseRoute: "products", + Endpoints: [ + ep({ HttpMethod: "POST", Route: "/", RequestDTORef: "CreateProductDto" }), + ep({ HttpMethod: "GET", Route: "/" }), + ep({ HttpMethod: "DELETE", Route: ":id", PathParams: [{ Name: "id", Type: "string" }] }), + ], + }); + // CreateProductDto gercek bir DTO node'u (dangling-ref kurali tetiklenmesin). + const dto = node("DTO", "db300000-0000-4000-8000-000000000001", { + Name: "CreateProductDto", + Description: "urun girdisi", + Fields: [{ Name: "name", DataType: "string", IsRequired: true, IsArray: false }], + }); + expect(lintContracts(buildCodeGraph([ctrl, dto], []))).toHaveLength(0); + }); + + it("RequiredRoles var ama RequiresAuth yok -> uyari (rol auth olmadan enforce edilemez)", () => { + // RolesGuard request.user.role'e bakar; AuthGuard yoksa request.user set edilmez -> + // RolesGuard her istegi reddeder -> endpoint erisilemez. Kontrat ihlali. + const ctrl = node("Controller", CTRL, { + ControllerName: "AdminController", + Description: "yonetim", + BaseRoute: "admin", + Endpoints: [ + ep({ HttpMethod: "GET", Route: "panel", RequiresAuth: false, RequiredRoles: ["admin"], ResponseDTORef: "PanelDto" }), + ], + }); + const warnings = lintContracts(buildCodeGraph([ctrl], [])); + expect(warnings.some((w) => /role/i.test(w) && /auth/i.test(w))).toBe(true); + }); + + it("RequiredRoles + RequiresAuth birlikte -> auth uyarisi NONE", () => { + const ctrl = node("Controller", CTRL, { + ControllerName: "AdminController", + Description: "yonetim", + BaseRoute: "admin", + Endpoints: [ + ep({ HttpMethod: "GET", Route: "panel", RequiresAuth: true, RequiredRoles: ["admin"], ResponseDTORef: "PanelDto" }), + ], + }); + const warnings = lintContracts(buildCodeGraph([ctrl], [])); + expect(warnings.some((w) => /role/i.test(w) && /auth/i.test(w))).toBe(false); + }); + + it("route param'i eslesen PathParam'siz -> uyari (handler okuyamaz)", () => { + // GET /:id ama PathParams bos -> @Param("id") uretilmez, handler id'yi okuyamaz. + const dto = node("DTO", "da100000-0000-4000-8000-000000000001", { + Name: "OrderDto", + Description: "order", + Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], + }); + const ctrl = node("Controller", CTRL, { + ControllerName: "OrderController", + Description: "order", + BaseRoute: "orders", + Endpoints: [ep({ HttpMethod: "GET", Route: ":id", PathParams: [], ResponseDTORef: "OrderDto" })], + }); + const warnings = lintContracts(buildCodeGraph([ctrl, dto], [])); + expect(warnings.some((w) => /route parameter/i.test(w) && /\bid\b/.test(w))).toBe(true); + }); + + it("PathParam route'la eslesince -> route-param uyarisi NONE", () => { + const dto = node("DTO", "da200000-0000-4000-8000-000000000001", { + Name: "OrderDto", + Description: "order", + Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], + }); + const ctrl = node("Controller", CTRL, { + ControllerName: "OrderController", + Description: "order", + BaseRoute: "orders", + Endpoints: [ep({ HttpMethod: "GET", Route: ":id", PathParams: [{ Name: "id", Type: "string" }], ResponseDTORef: "OrderDto" })], + }); + const warnings = lintContracts(buildCodeGraph([ctrl, dto], [])); + expect(warnings.some((w) => /route parameter/i.test(w))).toBe(false); + }); + + it("cozulemeyen DTO ref (Request/Response) -> uyari (var olmayan DTO)", () => { + const ctrl = node("Controller", CTRL, { + ControllerName: "ProductController", + Description: "urun", + BaseRoute: "products", + Endpoints: [ + ep({ HttpMethod: "POST", Route: "/", RequestDTORef: "GhostInput", ResponseDTORef: "GhostOutput" }), + ], + }); + // GhostInput/GhostOutput DTO node'u NONE -> dangling ref. + const warnings = lintContracts(buildCodeGraph([ctrl], [])); + expect(warnings.some((w) => /GhostInput/.test(w) && /exist|resolve/i.test(w))).toBe(true); + expect(warnings.some((w) => /GhostOutput/.test(w))).toBe(true); + }); + + it("Repository EntityReference cozulemezse -> uyari (Repository fallback)", () => { + const repo = node("Repository", "dc100000-0000-4000-8000-000000000001", { + RepositoryName: "GhostRepository", + Description: "baglantisiz", + EntityReference: "Phantom", + IsCached: false, + CustomQueries: [], + }); + const warnings = lintContracts(buildCodeGraph([repo], [])); + expect(warnings.some((w) => /Phantom/.test(w) && /resolve|Model or Table|exist/i.test(w))).toBe(true); + }); + + it("Service Dependency Ref cozulemezse -> uyari (import'suz inject)", () => { + const svc = node("Service", "dc200000-0000-4000-8000-000000000001", { + ServiceName: "OrderService", + Description: "order", + IsTransactionScoped: false, + Methods: [{ MethodName: "doThing", Visibility: "public", Parameters: [], ReturnType: "void", IsAsync: true, Throws: [] }], + Dependencies: [{ Kind: "Repository", Ref: "GhostRepo" }], + }); + const warnings = lintContracts(buildCodeGraph([svc], [])); + expect(warnings.some((w) => /GhostRepo/.test(w) && /resolve|exist/i.test(w))).toBe(true); + }); + + it("Kural 6: DTO zorunlu alani NULLABLE kolondan besleniyor -> uyari; NOT NULL/optional -> uyari yok", () => { + const table = node("Table", "dt100000-0000-4000-8000-000000000001", { + TableName: "Videos", + Description: "videolar", + Columns: [ + { Name: "Id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + { Name: "Title", DataType: "VARCHAR", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + { Name: "VideoUrl", DataType: "VARCHAR", IsPrimaryKey: false, IsNotNull: false, IsUnique: false, AutoIncrement: false }, + ], + }); + const dto = node("DTO", "dd100000-0000-4000-8000-000000000001", { + Name: "VideoDTO", + Description: "video ciktisi", + Fields: [ + { Name: "Title", DataType: "string", IsRequired: true, IsArray: false }, // NOT NULL → uyari yok + { Name: "VideoUrl", DataType: "string", IsRequired: true, IsArray: false }, // nullable kolon + zorunlu → UYARI + { Name: "Description", DataType: "string", IsRequired: false, IsArray: false }, // optional → uyari yok + ], + }); + const warnings = lintContracts(buildCodeGraph([table, dto], [])); + const nullWarn = warnings.filter((w) => /is required but its source column/.test(w)); + expect(nullWarn).toHaveLength(1); + expect(nullWarn[0]).toContain("VideoDTO.VideoUrl"); + expect(nullWarn[0]).toContain("Videos.VideoUrl"); + }); + + it("Kural 6: entity-bagli olmayan DTO (eslesen tablo yok) -> nullability uyarisi yok", () => { + const dto = node("DTO", "dd200000-0000-4000-8000-000000000001", { + Name: "LoginRequestDTO", + Description: "giris", + Fields: [{ Name: "email", DataType: "string", IsRequired: true, IsArray: false }], + }); + const warnings = lintContracts(buildCodeGraph([dto], [])); + expect(warnings.some((w) => /is required but its source column/.test(w))).toBe(false); + }); + + it("DETERMINISM: uyarilar sirali + tekrar uretimde ayni", () => { + const ctrl = node("Controller", CTRL, { + ControllerName: "MixController", + Description: "karisik", + BaseRoute: "mix", + Endpoints: [ + ep({ HttpMethod: "POST", Route: "zeta" }), + ep({ HttpMethod: "POST", Route: "alpha" }), + ], + }); + const a = lintContracts(buildCodeGraph([ctrl], [])); + const b = lintContracts(buildCodeGraph([ctrl], [])); + expect(a).toEqual(b); + expect([...a].sort()).toEqual(a); + }); +}); diff --git a/apps/server/src/codegen/contract-lint.ts b/apps/server/src/codegen/contract-lint.ts new file mode 100644 index 0000000..4537616 --- /dev/null +++ b/apps/server/src/codegen/contract-lint.ts @@ -0,0 +1,152 @@ +import { propsOf, type CodeGraph } from "./ir"; + +/* ──────────────────────────────────────────────────────────────────────── + * contract-lint.ts — DIYAGRAM-ANI KONTRAT DENETIMI. + * + * Graf'in YAPISAL eksiklerini codegen uyarisina cevirir: uretim BASARILI olur ama + * kullaniciya bildirilir (GeneratedProject.warnings -> canvas bunlari isaretler). + * Felsefe: emitter graf ne diyorsa onu uretir; eksik bir kontrati emitter'da + * "uydurmak" yerine burada YUKSEK SESLE yakala (L1 Contract-Compiler'in cekirdegi). + * + * Simdiki kural: + * - Govde-alan write endpoint'i (POST/PUT/PATCH) bir input DTO'su (RequestDTORef) + * WITHOUT -> @Body uretilemez, istek govdesi sessizce yok sayilir. (surgical-output + * bug'i: category POST / order PATCH'te @Body yoktu, AI placeholder uydurdu.) + * + * SAF + DETERMINISTIC: yalniz graf okumasi, sirali cikti, yan etki yok. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Bir istek govdesi (body) bekleyebilen HTTP fiilleri. GET/DELETE govdesizdir. */ +const WRITE_METHODS: ReadonlySet = new Set(["POST", "PUT", "PATCH"]); + +/** Graf uzerinde kontrat denetimi kosar; bulunan ihlalleri sirali uyari listesi + * olarak dondurur (ihlal yoksa bos dizi). codegen.service.assemble bunu + * graph.warnings() ile birlestirir. */ +export function lintContracts(graph: CodeGraph): string[] { + const warnings: string[] = []; + for (const ctrl of graph.allOf("Controller")) { + const props = propsOf<"Controller">(ctrl); + for (const ep of props.Endpoints ?? []) { + // Kural 1: govde-alan write endpoint'i (POST/PUT/PATCH) input DTO'su olmadan. + if (WRITE_METHODS.has(ep.HttpMethod) && !ep.RequestDTORef) { + warnings.push( + `${ctrl.name}: ${ep.HttpMethod} ${ep.Route} has no request body DTO ` + + `(RequestDTORef) — the request body is ignored. Connect an input DTO to this endpoint.`, + ); + } + // Kural 2: rol gerektiren ama auth gerektirmeyen endpoint. RolesGuard + // request.user.role'e bakar; AuthGuard (authentication) yoksa request.user + // set edilmez -> RolesGuard her istegi reddeder -> endpoint ERISILEMEZ. + if ((ep.RequiredRoles?.length ?? 0) > 0 && !ep.RequiresAuth) { + warnings.push( + `${ctrl.name}: ${ep.HttpMethod} ${ep.Route} requires roles but not authentication ` + + `(RequiresAuth=false) — roles cannot be enforced without an authenticated user; the ` + + `endpoint becomes unreachable. Enable authentication on this endpoint.`, + ); + } + + // Kural 3: route ":param"'i eslesen PathParam'siz. Emitter @Param("x")'i + // PathParams'tan uretir; route ":x" ama PathParam yoksa handler x'i OKUYAMAZ. + const declaredParams = new Set((ep.PathParams ?? []).map((p) => p.Name)); + for (const rp of routeParamNames(ep.Route)) { + if (!declaredParams.has(rp)) { + warnings.push( + `${ctrl.name}: ${ep.HttpMethod} ${ep.Route} declares a route parameter ":${rp}" ` + + `with no matching PathParam — the handler cannot read it. Add a path parameter "${rp}".`, + ); + } + } + + // Kural 4: DANGLING DTO ref — RequestDTORef/ResponseDTORef bir DTO node'una + // cozulmuyor -> emitter `unknown /* TODO */` uretir; baglanti eksik/yanlis. + const dtoRefs: ReadonlyArray = [ + ["request", ep.RequestDTORef], + ["response", ep.ResponseDTORef], + ]; + for (const [kind, ref] of dtoRefs) { + if (ref && !graph.resolveRef("DTO", ref)) { + warnings.push( + `${ctrl.name}: ${ep.HttpMethod} ${ep.Route} ${kind} DTO "${ref}" does not exist — ` + + `no DTO node resolves it. Add the DTO or fix the reference.`, + ); + } + } + } + } + + // Kural 5: DANGLING entity/dependency ref'leri (kopuk baglantilar). Emitter bunlari + // tolere eder (Repository / import'suz inject) ama graf baglantisi eksik/yanlis. + for (const repo of graph.allOf("Repository")) { + const ref = propsOf<"Repository">(repo).EntityReference; + if (ref && !graph.resolveRef(["Model", "Table"], ref)) { + warnings.push( + `${repo.name}: entity reference "${ref}" does not resolve to a Model or Table — ` + + `the repository falls back to Repository. Fix the entity reference.`, + ); + } + } + for (const svc of graph.allOf("Service")) { + for (const dep of propsOf<"Service">(svc).Dependencies ?? []) { + if (!graph.resolveRef(dep.Kind, dep.Ref)) { + warnings.push( + `${svc.name}: dependency "${dep.Ref}" (${dep.Kind}) does not resolve to a node — ` + + `it is injected without a valid import. Fix the dependency reference.`, + ); + } + } + } + + // Kural 6: NULLABILITY uyumsuzlugu — bir DTO ZORUNLU alani (IsRequired), ayni-isimli entity + // tablosunda NULLABLE bir kolondan (IsNotNull=false) besleniyor. Codegen ikisini de sadik + // uretir (entity `x?: T`, dto `x: T`); fill nullable kaynagi zorunlu hedefe KOPRULEMEK zorunda + // (default/throw) yoksa TS2322. Surgical AI bunu artik kopruler, ama celiskiyi KAYNAGINDA + // (diyagramda) yakala. Eslestirme isim-bazli (VideoDTO → Videos tablosu) → yalniz aday tablo + // VE ayni-isimli kolon varken uyarir (dar, dusuk false-positive). Uyari bloklamaz. + for (const dto of graph.allOf("DTO")) { + const entityName = dto.name.replace(/(DTO|Dto)$/, ""); + if (entityName.length === 0) continue; + const table = findEntityTable(graph, entityName); + if (!table) continue; + const cols = new Map((propsOf<"Table">(table).Columns ?? []).map((c) => [c.Name.toLowerCase(), c])); + for (const f of propsOf<"DTO">(dto).Fields ?? []) { + if (!f.IsRequired) continue; + const col = cols.get(f.Name.toLowerCase()); + if (col && col.IsNotNull === false) { + warnings.push( + `${dto.name}.${f.Name} is required but its source column ${table.name}.${col.Name} is nullable — ` + + `the generated code maps a nullable value into a required field (the fill bridges it with a default or ` + + `throw). To remove the friction, make the column NOT NULL or the DTO field optional.`, + ); + } + } + } + + return warnings.sort(); +} + +/** Bir entity ADI icin (VideoDTO'dan turetilmis "Video") eslesen Table node'unu bulur: + * dogrudan / tekil↔cogul (Video↔Videos, -ies/-y) eslesmesi, buyuk-kucuk harf duyarsiz. + * Aday yoksa null → DTO entity-bagli degil (request/aggregate DTO'su) → lint atlar. + * Donus tipi cikarimla (CodeNode | null) — ir.ts CodeNode'u export etmez. */ +function findEntityTable(graph: CodeGraph, entityName: string) { + const en = entityName.toLowerCase(); + const variants = new Set([en, en + "s", en + "es", en.replace(/y$/, "ies")]); + if (en.endsWith("s")) variants.add(en.slice(0, -1)); + if (en.endsWith("ies")) variants.add(en.slice(0, -3) + "y"); + for (const t of graph.allOf("Table")) { + if (variants.has(t.name.toLowerCase())) return t; + } + return null; +} + +/** Bir route'taki parametre adlari: ":id" / "{id}" segmentlerinden ad'lari cikarir. */ +function routeParamNames(route: string): string[] { + return route + .split("/") + .filter((s) => s.length > 0) + .flatMap((seg) => { + if (seg.startsWith(":")) return [seg.slice(1)]; + if (seg.startsWith("{") && seg.endsWith("}")) return [seg.slice(1, -1)]; + return []; + }); +} diff --git a/apps/server/src/codegen/dto/codegen.dto.ts b/apps/server/src/codegen/dto/codegen.dto.ts new file mode 100644 index 0000000..5b67956 --- /dev/null +++ b/apps/server/src/codegen/dto/codegen.dto.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import { createZodDto } from "nestjs-zod"; + +/** POST /projects/:projectId/codegen body. target optional (default nestjs). */ +export const CodegenRequestSchema = z + .object({ + target: z.enum(["nestjs"]).default("nestjs"), + }) + .strict(); + +export type CodegenRequest = z.infer; + +export class CodegenRequestDto extends createZodDto(CodegenRequestSchema) {} diff --git a/apps/server/src/codegen/emitters/nestjs/api-gateway.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/api-gateway.emitter.spec.ts new file mode 100644 index 0000000..17dd8c9 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/api-gateway.emitter.spec.ts @@ -0,0 +1,287 @@ +import { describe, it, expect } from "vitest"; +import { emitApiGateway } from "./api-gateway.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; +import type { EdgeKind } from "../../../edges/schemas/edge.schema"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ + +const PROJECT = "00000000-0000-4000-8000-000000000000"; +const HOME_TAB = "22222222-2222-4222-8222-222222222222"; + +function node( + type: StoredNode["type"], + properties: Record, + id: string, +): StoredNode { + return { + id, + type, + projectId: PROJECT, + positionX: 0, + positionY: 0, + homeTabId: HOME_TAB, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function edge( + kind: EdgeKind, + sourceNodeId: string, + targetNodeId: string, + id: string, +): StoredEdge { + return { + id, + projectId: PROJECT, + sourceNodeId, + targetNodeId, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: false }, + }; +} + +function ctxFor(nodes: StoredNode[], edges: StoredEdge[] = []): { ctx: EmitterContext } { + const graph = buildCodeGraph(nodes, edges); + return { ctx: { graph, target: "nestjs" } }; +} + +/* ── id sabitleri ──────────────────────────────────────────────────────── */ +const GW = "11111111-1111-4111-8111-111111111111"; +const USERS_CTRL = "33333333-3333-4333-8333-333333333333"; +const AUTH_SVC = "44444444-4444-4444-8444-444444444444"; + +/* ── Gateway fixture'lari ──────────────────────────────────────────────── */ +function gatewayNode(props: Partial> = {}, id = GW): StoredNode { + return node( + "APIGateway", + { + GatewayName: "PublicApiGateway", + Description: "Genel API girisi", + Provider: "Kong", + Routes: [], + ...props, + }, + id, + ); +} + +function usersController(id = USERS_CTRL): StoredNode { + return node( + "Controller", + { ControllerName: "UsersController", Description: "User uclari", BaseRoute: "users", Endpoints: [] }, + id, + ); +} + +describe("emitApiGateway", () => { + it("rol-tekrarsiz .gateway.ts yolu (APIGateway eki duser, base = Public)", () => { + // B2: Gateway gercek bir @Controller -> Controller gibi KENDI feature'ini + // tohumlar (hedef cozulmese bile orphan kalmasin). "PublicApiGateway" en- + // spesifik "APIGateway" ekiyle eslesir -> base "Public" -> feature "public". + const gw = gatewayNode(); + const { ctx } = ctxFor([gw]); + const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); + expect(file.path).toBe("public/public.gateway.ts"); + }); + + it("@Controller() sinifi (Injectable NOT) + Provider/Description doc-comment", () => { + // B2: Gateway artik GERCEK bir @Controller'dir (orphan @Injectable degil) -> + // feature module'un controllers'ina girer; NestJS routing'i otomatik baglar. + const gw = gatewayNode(); + const { ctx } = ctxFor([gw]); + const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); + expect(file.content).toContain("@Controller()"); + expect(file.content).not.toContain("@Injectable()"); + expect(file.content).toContain("export class PublicApiGateway {"); + expect(file.content).toContain(" * Genel API girisi"); + expect(file.content).toContain(" * API Gateway (Provider: Kong)."); + expect(file.content).toContain('import { Controller'); + expect(file.content).toContain('from "@nestjs/common";'); + }); + + it("Routes[].TargetRef Service'e cozulur -> DI + goreli import + @Get + delegasyon ipucu", () => { + // B2: Gateway YALNIZ Service enjekte eder (Controller anti-pattern -> DI'a alinmaz). + const gw = gatewayNode({ + Routes: [ + { + Path: "/users/:id", + TargetRef: "AuthService", + Methods: ["GET"], + AuthRequired: false, + }, + ], + }); + const svc = node("Service", { ServiceName: "AuthService", Methods: [] }, AUTH_SVC); + // CALLS edge'i ile gateway, service'in feature'ina (auth) duser. + const { ctx } = ctxFor([gw, svc], [edge("CALLS", GW, AUTH_SVC, "55555555-5555-4555-8555-555555555555")]); + const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); + // DI: constructor'da AuthService (Service). + expect(file.content).toContain("private readonly authService: AuthService,"); + // goreli import (feature=auth; gateway de auth altinda). + expect(file.content).toContain('import { AuthService } from "./auth.service";'); + // HTTP dekoratorlu route metodu + delegasyon ipucu marker'da. + expect(file.content).toContain('@Get("users/:id")'); + expect(file.content).toContain("async dispatchGetUsersById(): Promise {"); + expect(file.content).toContain("// Delegation hint: this.authService.(...)."); + }); + + it("wildcard route (api/auth/*) -> GECERLI metot adi (`*` identifier'a sizmaz, TS1434/TS1003 onle)", () => { + // Gercek bug: route Path "/api/auth/*" -> metot adi "dispatchGetApiAuth*" uretiliyordu; + // `*` gecersiz identifier -> sozdizimi hatasi (derleme kirilir). Wildcard -> "All". + const gw = gatewayNode({ + Routes: [{ Path: "/api/auth/*", TargetRef: "AuthService", Methods: ["GET"], AuthRequired: false }], + }); + const svc = node("Service", { ServiceName: "AuthService", Methods: [] }, AUTH_SVC); + const { ctx } = ctxFor([gw, svc], [edge("CALLS", GW, AUTH_SVC, "55555555-5555-4555-8555-555555555555")]); + const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); + // Metot adinda `*` (veya baska identifier-disi karakter) NONE. + expect(file.content).not.toMatch(/async dispatch\w*[^A-Za-z0-9(]/); + expect(file.content).toContain("async dispatchGetApiAuthAll(): Promise {"); + // Route argumani `*`'i KORUR (Express wildcard'i runtime'da gecerli). + expect(file.content).toContain('@Get("api/auth/*")'); + }); + + it("Controller hedefi DI'a ALINMAZ (anti-pattern); metot yine uretilir + marker'da not", () => { + // B2: Bir route Controller'a isaret ederse DI ile enjekte edilmez (controller'a + // HTTP ile gidilir). Constructor bostur; route metodu marker'da not tasir. + const gw = gatewayNode({ + Routes: [ + { Path: "/users/:id", TargetRef: "UsersController", Methods: ["GET"], AuthRequired: false }, + ], + }); + const ctrl = usersController(); + const { ctx } = ctxFor([gw, ctrl], [edge("ROUTES_TO", GW, USERS_CTRL, "55555555-5555-4555-8555-555555555555")]); + const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); + expect(file.content).not.toContain("private readonly usersController"); + expect(file.content).not.toContain("constructor("); + expect(file.content).toContain('// TODO: target "UsersController" did not resolve to a Service (Controller targets are not injected via DI).'); + expect(file.content).toContain("async dispatchGetUsersById(): Promise {"); + }); + + it("her route bir surgical-marker'li @-dekoratorlu metot + NOT_IMPLEMENTED govdesi", () => { + const gw = gatewayNode({ + Routes: [ + { Path: "/login", TargetRef: "AuthService", Methods: ["POST"], AuthRequired: false }, + ], + }); + const svc = node("Service", { ServiceName: "AuthService", Methods: [] }, AUTH_SVC); + const { ctx } = ctxFor([gw, svc], [edge("CALLS", GW, AUTH_SVC, "66666666-6666-4666-8666-666666666666")]); + const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); + expect(file.surgicalMarkers).toBe(1); + expect(file.content).toContain('@Post("login")'); + expect(file.content).toContain(`// @solarch:surgical id=${GW}#dispatchPostLogin`); + expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: PublicApiGateway.dispatchPostLogin");'); + expect(file.content).toContain("private readonly authService: AuthService,"); + }); + + it("AuthRequired + RateLimit ipuclari marker aciklamasinda", () => { + const gw = gatewayNode({ + Routes: [ + { + Path: "/admin", + TargetRef: "AuthService", + Methods: ["GET"], + AuthRequired: true, + RateLimit: { Requests: 100, WindowSeconds: 60 }, + }, + ], + }); + const svc = node("Service", { ServiceName: "AuthService", Methods: [] }, AUTH_SVC); + const { ctx } = ctxFor([gw, svc], [edge("CALLS", GW, AUTH_SVC, "66666666-6666-4666-8666-666666666666")]); + const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); + expect(file.content).toContain("// Requires auth (AuthRequired=true)."); + expect(file.content).toContain("// Rate limit: 100 requests / 60s."); + }); + + it("kayip TargetRef -> THROW yok, DI yok, marker'da TODO ipucu", () => { + const gw = gatewayNode({ + Routes: [ + { Path: "/ghost", TargetRef: "GhostService", Methods: ["GET"], AuthRequired: false }, + ], + }); + const { ctx } = ctxFor([gw]); + expect(() => emitApiGateway(ctx.graph.byId(GW)!, ctx)).not.toThrow(); + const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); + expect(file.content).not.toContain("constructor("); + expect(file.content).toContain('// TODO: target "GhostService" did not resolve to a Service'); + expect(file.content).toContain("async dispatchGetGhost(): Promise {"); + }); + + it("ayni dispatch adi -> deterministik tekillestirme (2, 3)", () => { + const gw = gatewayNode({ + Routes: [ + { Path: "/users", TargetRef: "AuthService", Methods: ["GET"], AuthRequired: false }, + { Path: "/users", TargetRef: "AuthService", Methods: ["GET"], AuthRequired: false }, + ], + }); + const svc = node("Service", { ServiceName: "AuthService", Methods: [] }, AUTH_SVC); + const { ctx } = ctxFor([gw, svc], [edge("CALLS", GW, AUTH_SVC, "66666666-6666-4666-8666-666666666666")]); + const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); + expect(file.content).toContain("async dispatchGetUsers(): Promise {"); + expect(file.content).toContain("async dispatchGetUsers2(): Promise {"); + }); + + it("DI hedefleri SADECE Service + DEDUP + isme gore sirali (Routes ∪ ROUTES_TO ∪ CALLS)", () => { + const gw = gatewayNode({ + Routes: [ + { Path: "/a", TargetRef: "BillingService", Methods: ["GET"], AuthRequired: false }, + { Path: "/b", TargetRef: "AuthService", Methods: ["GET"], AuthRequired: false }, + ], + }); + const auth = node("Service", { ServiceName: "AuthService", Methods: [] }, AUTH_SVC); + const billing = node("Service", { ServiceName: "BillingService", Methods: [] }, "88888888-8888-4888-8888-888888888888"); + // CALLS ayrica AuthService'e -> DEDUP (tek alan kalmali). + const { ctx } = ctxFor( + [gw, auth, billing], + [edge("CALLS", GW, AUTH_SVC, "77777777-7777-4777-8777-777777777777")], + ); + const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); + // Isme gore sirali: AuthService once, BillingService sonra. + const authIdx = file.content.indexOf("authService: AuthService"); + const billingIdx = file.content.indexOf("billingService: BillingService"); + expect(authIdx).toBeGreaterThan(-1); + expect(billingIdx).toBeGreaterThan(authIdx); + // DEDUP: AuthService constructor'da bir kez. + expect(file.content.match(/authService: AuthService/g)?.length).toBe(1); + }); + + it("rol eki adin tamamiysa orijinal ad korunur (Gateway -> gateway/gateway.gateway.ts)", () => { + // B2: Hedefsiz gateway de kendi feature'ini tohumlar (orphan degil). Rol eki + // ("Gateway") adin tamami -> base "gateway" -> feature "gateway". + const gw = gatewayNode({ GatewayName: "Gateway" }); + const { ctx } = ctxFor([gw]); + const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); + expect(file.path).toBe("gateway/gateway.gateway.ts"); + expect(file.content).toContain("export class Gateway {"); + }); + + it("content ends with single newline", () => { + const gw = gatewayNode(); + const { ctx } = ctxFor([gw]); + const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: same node twice -> byte-identical", () => { + const gw = gatewayNode({ + Routes: [ + { Path: "/users/:id", TargetRef: "UsersController", Methods: ["GET"], AuthRequired: false }, + { Path: "/login", TargetRef: "AuthService", Methods: ["POST"], AuthRequired: true }, + ], + }); + const svc = node("Service", { ServiceName: "AuthService", Methods: [] }, AUTH_SVC); + const { ctx } = ctxFor([gw, usersController(), svc]); + const a = emitApiGateway(ctx.graph.byId(GW)!, ctx)[0].content; + const b = emitApiGateway(ctx.graph.byId(GW)!, ctx)[0].content; + expect(a).toBe(b); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/api-gateway.emitter.ts b/apps/server/src/codegen/emitters/nestjs/api-gateway.emitter.ts new file mode 100644 index 0000000..5a5a5a7 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/api-gateway.emitter.ts @@ -0,0 +1,292 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import type { CodeGraph, CodeNode } from "../../ir"; +import { + camelCase, + filePathFor, + importPathOf, + pascalCase, + relativeImportPath, +} from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers, notImplemented, surgicalMarker } from "../../surgical"; +import type { APIGatewayNode } from "../../../nodes/schemas/api-gateway.schema"; + +/* ──────────────────────────────────────────────────────────────────────── + * api-gateway.emitter.ts — APIGatewayNode -> /.gateway.ts. + * + * ARCHITECTURE DECISION (B2): An API Gateway is an HTTP ENTRY LAYER routing to + * backend providers. We emit it as a REAL NestJS @Controller — the previous + * @Injectable() gateway was NEVER wired into any feature module (dead code) + + * injected Controllers in constructor (anti-pattern). Now: + * - @Controller() class: each route becomes an HTTP-decorated (@Get/@Post/...) + * method -> NestJS routing wires automatically (no orphan; ir.ts feature + * inference puts gateway in feature's @Module.controllers). + * - DI takes ONLY Services (NOT Controller — anti-pattern fixed). + * Targets: Routes[].TargetRef (Service) ∪ ROUTES_TO/CALLS edges (Service). + * If route points to Controller it is NOT injected (HTTP to controller, not DI); + * method still generated, note left in marker. + * + * Schema (api-gateway.schema.ts) property names verbatim: + * GatewayName, Description, Provider, AuthMode?, CorsEnabled?, + * Routes: { Path, TargetRef (→ Controller|Service Name), Methods[], + * AuthRequired, RateLimit? { Requests, WindowSeconds } }[] + * + * PURE + DETERMINISTIC: collections sorted, missing refs tolerated (no THROW), + * imports via ImportCollector, no timestamp/random, content ends with single "\n". + * ──────────────────────────────────────────────────────────────────────── */ + +type GatewayProps = APIGatewayNode["properties"]; +type GatewayRoute = GatewayProps["Routes"][number]; + +/** A resolved Service target this gateway receives via DI. */ +interface ResolvedService { + /** constructor `this.` */ + field: string; + /** injected class name (pascalCase(name)) */ + className: string; + /** resolved Service name (for route matching) */ + name: string; +} + +const HTTP_DECORATOR: Record = { + GET: "Get", + POST: "Post", + PUT: "Put", + DELETE: "Delete", + PATCH: "Patch", +}; + +export const emitApiGateway: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + // APIGateway NOT in PropsByKind -> cannot use propsOf; cast properties to + // schema type (DB already Zod-validated; no runtime transform). + const props = node.properties as GatewayProps; + const graph = ctx.graph; + const className = pascalCase(node.name); + const filePath = filePathFor(node, graph); + + const imports = new ImportCollector(); + imports.add("Controller", "@nestjs/common"); + + // ── Routes: preserve schema order (user intent) but dedupe method names + // deterministically. Each route produces one HTTP-decorated method. ── + const routes = props.Routes ?? []; + + // ── DI targets: ONLY Services (Controller anti-pattern -> excluded). + // Routes[].TargetRef ∪ ROUTES_TO ∪ CALLS. DEDUP + sorted by name. ── + const services = collectServiceTargets(node, routes, graph, filePath, imports); + + // ── Method name dedupe counter (same name -> "2", "3" ...). ── + const usedNames = new Map(); + const methodBlocks: string[] = []; + for (const route of routes) { + methodBlocks.push(renderRoute(node, className, route, services, imports, usedNames)); + } + + // ── Class body ── + const lines: string[] = []; + lines.push(gatewayDocComment(props)); + // @Controller() — no base prefix; each route carries Path as-is + // (Paths are full paths in schema; avoid double prefix risk). + lines.push("@Controller()"); + lines.push(`export class ${className} {`); + + if (services.length > 0) { + lines.push(" constructor("); + for (const s of services) { + lines.push(` private readonly ${s.field}: ${s.className},`); + } + lines.push(" ) {}"); + if (methodBlocks.length > 0) lines.push(""); + } + + methodBlocks.forEach((block, i) => { + lines.push(block); + if (i < methodBlocks.length - 1) lines.push(""); + }); + + lines.push("}"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: filePath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/* ── Collect DI targets (Services ONLY) ───────────────────────────────── */ + +/** DEDUP + name-sorted ResolvedService list from Routes[].TargetRef (Service) + * ∪ ROUTES_TO/CALLS edge targets (Service). Controller targets NOT added to DI + * (anti-pattern); missing refs skipped. Never throws. */ +function collectServiceTargets( + node: CodeNode, + routes: readonly GatewayRoute[], + graph: CodeGraph, + filePath: string, + imports: ImportCollector, +): ResolvedService[] { + const byId = new Map(); + + // (1) Routes[].TargetRef -> Service (skip Controller). + for (const r of routes) { + const resolved = graph.resolveRef("Service", r.TargetRef); + if (resolved) byId.set(resolved.id, resolved); + } + // (2) ROUTES_TO edge targets (Service only). + for (const e of graph.outEdges(node.id, "ROUTES_TO")) { + const tgt = graph.byId(e.targetNodeId); + if (tgt && tgt.kindOf() === "Service") byId.set(tgt.id, tgt); + } + // (3) CALLS edge targets (Service only). + for (const e of graph.outEdges(node.id, "CALLS")) { + const tgt = graph.byId(e.targetNodeId); + if (tgt && tgt.kindOf() === "Service") byId.set(tgt.id, tgt); + } + + const resolved = [...byId.values()].sort(byNameThenId).map((t) => ({ + field: camelCase(t.name), + className: pascalCase(t.name), + name: t.name, + })); + + for (const t of [...byId.values()].sort(byNameThenId)) { + imports.add(pascalCase(t.name), importPathOf(relativeImportPath(filePath, filePathFor(t, graph)))); + } + return resolved; +} + +/* ── Single route -> HTTP-decorated method ──────────────────────────────────── */ + +/** Convert one route to @Get/@Post/... decorated method with surgical marker. + * When TargetRef resolves to Service, delegation hint (this.) in marker. */ +function renderRoute( + node: CodeNode, + className: string, + route: GatewayRoute, + services: ResolvedService[], + imports: ImportCollector, + usedNames: Map, +): string { + const indent = " "; + const methodName = uniqueName(deriveRouteMethodName(route), usedNames); + + // HTTP verb decorator (first method) + route path. + const verb = (route.Methods[0] ?? "GET").toUpperCase(); + const httpDecorator = HTTP_DECORATOR[verb] ?? "Get"; + imports.add(httpDecorator, "@nestjs/common"); + const routeArg = methodRouteArg(route.Path); + + // Resolved Service field (matches camelCase(TargetRef) in DI list). + const targetField = camelCase(route.TargetRef); + const delegate = services.find((s) => s.field === targetField); + + // ── Marker description: route summary + delegation + auth/rate-limit hints. ── + const descParts: string[] = []; + descParts.push(`${route.Methods.join("/")} ${route.Path} -> ${route.TargetRef}`); + if (delegate) { + descParts.push(`Delegation hint: this.${delegate.field}.(...).`); + } else { + descParts.push(`TODO: target "${route.TargetRef}" did not resolve to a Service (Controller targets are not injected via DI).`); + } + if (route.AuthRequired) { + descParts.push("Requires auth (AuthRequired=true)."); + } + if (route.RateLimit) { + descParts.push( + `Rate limit: ${route.RateLimit.Requests} requests / ${route.RateLimit.WindowSeconds}s.`, + ); + } + + const marker = surgicalMarker({ + nodeId: node.id, + member: methodName, + description: descParts.join("\n"), + deps: delegate ? [`this.${delegate.field}`] : undefined, + }); + + const lines: string[] = []; + lines.push(`${indent}@${httpDecorator}(${routeArg})`); + lines.push(`${indent}async ${methodName}(): Promise {`); + for (const ml of marker.split("\n")) lines.push(`${indent}${indent}${ml}`); + lines.push(`${indent}${indent}${notImplemented(className, methodName)}`); + lines.push(`${indent}}`); + return lines.join("\n"); +} + +/* ── Naming/helpers (DETERMINISTIC) ─────────────────────────────────────── */ + +/** Gateway class doc-comment: Description + Provider/Auth/CORS summary. */ +function gatewayDocComment(props: GatewayProps): string { + const lines: string[] = ["/**"]; + if (props.Description) lines.push(` * ${props.Description}`); + lines.push(` * API Gateway (Provider: ${props.Provider}).`); + if (props.AuthMode) lines.push(` * Auth: ${props.AuthMode}.`); + if (props.CorsEnabled !== undefined) lines.push(` * CORS: ${props.CorsEnabled ? "enabled" : "disabled"}.`); + lines.push(" */"); + return lines.join("\n"); +} + +/** Route method name: first HTTP verb + Path segments (literal -> Pascal; + * ":param"/"{param}" -> "By Param"). Empty -> "dispatch". E.g. + * GET /users/:id -> "dispatchGetUsersById". */ +function deriveRouteMethodName(route: GatewayRoute): string { + const verb = (route.Methods[0] ?? "GET").toLowerCase(); + const segments = route.Path.split("/").filter((s) => s.length > 0); + const words: string[] = ["dispatch", cap(verb)]; + for (const seg of segments) { + if (seg.startsWith(":")) { + words.push("By", ...splitSeg(seg.slice(1))); + } else if (seg.startsWith("{") && seg.endsWith("}")) { + words.push("By", ...splitSeg(seg.slice(1, -1))); + } else { + words.push(...splitSeg(seg)); + } + } + const name = words.join(""); + return name.length > 0 ? name : "dispatch"; +} + +/** @Get/@Post(...) route argument. ":id"/"{id}" -> ":id" Nest form; trim leading/trailing + * "/". Root ("/" or empty) -> no argument. */ +function methodRouteArg(path: string): string { + const norm = path + .split("/") + .filter((s) => s.length > 0) + .map((seg) => (seg.startsWith("{") && seg.endsWith("}") ? `:${seg.slice(1, -1)}` : seg)) + .join("/"); + return norm.length > 0 ? JSON.stringify(norm) : ""; +} + +/** Split path segment into Pascal words (camelCase/kebab/snake supported). + * Wildcard "*" -> "All" (route "/api/auth/*" -> "...ApiAuthAll"); non-identifier + * chars (*, +, etc.) treated as SEPARATORS -> do NOT leak into method name. + * Else invalid TS identifier like "dispatchGetApiAuth*" (TS1434/TS1003). */ +function splitSeg(seg: string): string[] { + return seg + .replace(/\*/g, " All ") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/[^A-Za-z0-9]+/) + .filter((w) => w.length > 0) + .map(cap); +} + +/** When same name appears again append "2", "3" ... (deterministic: route order preserved). */ +function uniqueName(base: string, used: Map): string { + const count = used.get(base) ?? 0; + used.set(base, count + 1); + return count === 0 ? base : `${base}${count + 1}`; +} + +function cap(w: string): string { + return w.length === 0 ? w : w[0].toUpperCase() + w.slice(1).toLowerCase(); +} + +function byNameThenId(a: CodeNode, b: CodeNode): number { + if (a.name !== b.name) return a.name < b.name ? -1 : 1; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} diff --git a/apps/server/src/codegen/emitters/nestjs/cache.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/cache.emitter.spec.ts new file mode 100644 index 0000000..9ea46f6 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/cache.emitter.spec.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from "vitest"; +import { emitCache } from "./cache.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +const PROJECT = "00000000-0000-4000-8000-000000000000"; +const TAB = "22222222-2222-4222-8222-222222222222"; +const CACHE = "33333333-3333-4333-8333-333333333333"; + +function cacheNode(properties: Record, id = CACHE): StoredNode { + return { + id, + type: "Cache", + projectId: PROJECT, + positionX: 0, + positionY: 0, + homeTabId: TAB, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function ctxFor(...nodes: StoredNode[]): { ctx: EmitterContext } { + const graph = buildCodeGraph(nodes, []); + return { ctx: { graph, target: "nestjs" } }; +} + +const DYNAMIC = { + CacheName: "ImageResultCache", + Description: "Caches generated image results", + KeyPattern: "image:result:{id}", + TTL_Seconds: 3600, + Engine: "Redis", +}; + +const STATIC = { + CacheName: "ConfigCache", + Description: "Application configuration cache", + KeyPattern: "app:config", + TTL_Seconds: 60, + Engine: "Memory", +}; + +describe("emitCache", () => { + it("dynamic key (KeyPattern with placeholder) — snapshot", () => { + const node = cacheNode(DYNAMIC); + const { ctx } = ctxFor(node); + const [file] = emitCache(ctx.graph.byId(node.id)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { CACHE_MANAGER } from "@nestjs/cache-manager"; + import { Inject, Injectable } from "@nestjs/common"; + import type { Cache } from "cache-manager"; + + /** + * Caches generated image results + * + * Engine: Redis · TTL: 3600s · KeyPattern: image:result:{id} + */ + @Injectable() + export class ImageResultCache { + /** Default TTL (cache-manager expects milliseconds). */ + private static readonly TTL_MS = 3600000; + /** Key template (Cache.KeyPattern). */ + private static readonly KEY_PATTERN = "image:result:{id}"; + + constructor( + @Inject(CACHE_MANAGER) private readonly cache: Cache, + ) {} + + /** Replaces the first \`{...}\` placeholder in KeyPattern with the suffix. */ + private buildKey(suffix: string | number): string { + return ImageResultCache.KEY_PATTERN.replace(/\\{[^}]*\\}/, String(suffix)); + } + + async get(suffix: string | number): Promise { + return this.cache.get(this.buildKey(suffix)); + } + + async set(suffix: string | number, value: T, ttlSeconds?: number): Promise { + const ttl = ttlSeconds !== undefined ? ttlSeconds * 1000 : ImageResultCache.TTL_MS; + await this.cache.set(this.buildKey(suffix), value, ttl); + } + + async del(suffix: string | number): Promise { + await this.cache.del(this.buildKey(suffix)); + } + } + ", + "language": "typescript", + "path": "common/image-result.cache.ts", + "surgicalMarkers": 0, + } + `); + }); + + it("static key (no placeholder) — get/set/del without parameters", () => { + const node = cacheNode(STATIC); + const { ctx } = ctxFor(node); + const [file] = emitCache(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain("private buildKey(): string {"); + expect(file.content).toContain("async get(): Promise {"); + expect(file.content).toContain("async set(value: T, ttlSeconds?: number): Promise {"); + expect(file.content).toContain("async del(): Promise {"); + expect(file.content).not.toContain("suffix"); + }); + + it("file path: Cache role suffix stripped, base kebab + .cache.ts", () => { + const node = cacheNode(DYNAMIC); + const { ctx } = ctxFor(node); + const [file] = emitCache(ctx.graph.byId(node.id)!, ctx); + expect(file.path).toBe("common/image-result.cache.ts"); + }); + + it("TTL_Seconds converted to ms (seconds*1000) and class is @Injectable", () => { + const node = cacheNode(DYNAMIC); + const { ctx } = ctxFor(node); + const [file] = emitCache(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain("private static readonly TTL_MS = 3600000;"); + expect(file.content).toContain("@Injectable()"); + expect(file.content).toContain("@Inject(CACHE_MANAGER) private readonly cache: Cache,"); + }); + + it("CACHE_MANAGER + Cache imports from correct packages", () => { + const node = cacheNode(STATIC); + const { ctx } = ctxFor(node); + const [file] = emitCache(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain('import { CACHE_MANAGER } from "@nestjs/cache-manager";'); + expect(file.content).toContain('import type { Cache } from "cache-manager";'); + expect(file.content).toContain('import { Inject, Injectable } from "@nestjs/common";'); + }); + + it("method bodies are real impl — no surgical markers", () => { + const node = cacheNode(DYNAMIC); + const { ctx } = ctxFor(node); + const [file] = emitCache(ctx.graph.byId(node.id)!, ctx); + expect(file.surgicalMarkers).toBe(0); + expect(file.content).not.toContain("@solarch:surgical"); + expect(file.content).not.toContain("NOT_IMPLEMENTED"); + }); + + it("content ends with single newline", () => { + const node = cacheNode(DYNAMIC); + const { ctx } = ctxFor(node); + const [file] = emitCache(ctx.graph.byId(node.id)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: same node twice -> byte-identical", () => { + const node = cacheNode(DYNAMIC); + const { ctx } = ctxFor(node); + const a = emitCache(ctx.graph.byId(node.id)!, ctx)[0].content; + const b = emitCache(ctx.graph.byId(node.id)!, ctx)[0].content; + expect(a).toBe(b); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/cache.emitter.ts b/apps/server/src/codegen/emitters/nestjs/cache.emitter.ts new file mode 100644 index 0000000..f5ae7c5 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/cache.emitter.ts @@ -0,0 +1,150 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import type { CodeNode } from "../../ir"; +import { filePathFor, pascalCase } from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers } from "../../surgical"; +import type { CacheNode } from "../../../nodes/schemas"; + +/* ──────────────────────────────────────────────────────────────────────── + * cache.emitter.ts — CacheNode -> /.cache.ts. + * + * Emits an @Injectable() NestJS cache service. Underlying store (Redis/Memcached/Memory) + * is injected via @nestjs/cache-manager's CACHE_MANAGER token; type is cache-manager's + * `Cache` interface. + * + * - KeyPattern: key template. When "{...}" placeholders exist, get/set/del take a + * `suffix` param substituted into placeholders; otherwise key is fixed (no params). + * buildKey() is the SINGLE SOURCE key builder. + * - TTL_Seconds: default TTL for set() (cache-manager expects ms -> *1000). + * - Engine: documented in header comment only (actual store comes via DI; Wire + * phase binds via CacheModule.register). + * + * Method bodies are REAL impl (get/set/del delegate to cache-manager) — + * no algorithm region, so no surgical marker needed. + * + * PURE + DETERMINISTIC: no collections, imports via ImportCollector, + * no timestamp/random, content ends with single "\n". + * ──────────────────────────────────────────────────────────────────────── */ + +/** Cache is NOT in ir.ts PropsByKind table (backend chain carries 9 types) -> + * propsOf<"Cache"> won't compile (TS2344). Type taken directly from Zod-inferred schema + * (DB is Zod-validated; type narrowing only, no runtime transform). */ +type CacheProps = CacheNode["properties"]; + +export const emitCache: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = node.properties as CacheProps; + const className = pascalCase(node.name); + const filePath = filePathFor(node, ctx.graph); + + const keyPattern = props.KeyPattern; + const ttlSeconds = props.TTL_Seconds; + // When "{...}" placeholder present, key is dynamic -> suffix param required. + const isDynamicKey = /\{[^}]*\}/.test(keyPattern); + + const imports = new ImportCollector(); + imports.add("Inject", "@nestjs/common"); + imports.add("Injectable", "@nestjs/common"); + imports.add("CACHE_MANAGER", "@nestjs/cache-manager"); + imports.addType("Cache", "cache-manager"); + + const lines: string[] = []; + + // ── Header: Description + Engine + TTL (deterministic). ────────────── + // Engine/TTL/KeyPattern line always meaningful; Description only when + // meaningful (trim >=3 char) -> avoid single-letter noise like "* s". + const hasDoc = typeof props.Description === "string" && props.Description.trim().length >= 3; + lines.push("/**"); + if (hasDoc) { + lines.push(` * ${props.Description.trim()}`); + lines.push(" *"); + } + lines.push(` * Engine: ${props.Engine} · TTL: ${ttlSeconds}s · KeyPattern: ${keyPattern}`); + lines.push(" */"); + lines.push("@Injectable()"); + lines.push(`export class ${className} {`); + + // ── Constants: TTL (ms) + key template. ───────────────────────────────── + lines.push(` /** Default TTL (cache-manager expects milliseconds). */`); + lines.push(` private static readonly TTL_MS = ${ttlSeconds * 1000};`); + lines.push(` /** Key template (Cache.KeyPattern). */`); + lines.push(` private static readonly KEY_PATTERN = ${JSON.stringify(keyPattern)};`); + lines.push(""); + + // ── Constructor: CACHE_MANAGER inject. ──────────────────────────────────── + lines.push(" constructor("); + lines.push(" @Inject(CACHE_MANAGER) private readonly cache: Cache,"); + lines.push(" ) {}"); + lines.push(""); + + // ── buildKey: single-source key builder. ─────────────────────────────── + if (isDynamicKey) { + lines.push(" /** Replaces the first `{...}` placeholder in KeyPattern with the suffix. */"); + lines.push(` private buildKey(suffix: string | number): string {`); + lines.push(` return ${className}.KEY_PATTERN.replace(/\\{[^}]*\\}/, String(suffix));`); + lines.push(" }"); + lines.push(""); + // get/set/del — dynamic key (suffix param). + lines.push(...method("get", "suffix: string | number", "Promise", [ + "return this.cache.get(this.buildKey(suffix));", + ], true)); + lines.push(""); + lines.push(...method("set", `suffix: string | number, value: T, ttlSeconds?: number`, "Promise", [ + // When ttlSeconds given convert to ms; else default TTL_MS (already ms). + `const ttl = ttlSeconds !== undefined ? ttlSeconds * 1000 : ${className}.TTL_MS;`, + "await this.cache.set(this.buildKey(suffix), value, ttl);", + ], true)); + lines.push(""); + lines.push(...method("del", "suffix: string | number", "Promise", [ + "await this.cache.del(this.buildKey(suffix));", + ], false)); + } else { + lines.push(" /** Static key (no placeholder in KeyPattern). */"); + lines.push(` private buildKey(): string {`); + lines.push(` return ${className}.KEY_PATTERN;`); + lines.push(" }"); + lines.push(""); + // get/set/del — fixed key (no params). + lines.push(...method("get", "", "Promise", [ + "return this.cache.get(this.buildKey());", + ], true)); + lines.push(""); + lines.push(...method("set", `value: T, ttlSeconds?: number`, "Promise", [ + // When ttlSeconds given convert to ms; else default TTL_MS (already ms). + `const ttl = ttlSeconds !== undefined ? ttlSeconds * 1000 : ${className}.TTL_MS;`, + "await this.cache.set(this.buildKey(), value, ttl);", + ], true)); + lines.push(""); + lines.push(...method("del", "", "Promise", [ + "await this.cache.del(this.buildKey());", + ], false)); + } + + lines.push("}"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: filePath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/** Produce lines for one async method (signature + body). `generic` true adds `` + * type param (get/set value type). Body is REAL impl; no algorithm region -> no surgical marker. */ +function method( + name: string, + params: string, + returnType: string, + bodyLines: string[], + generic: boolean, +): string[] { + const tp = generic ? "" : ""; + const out: string[] = [` async ${name}${tp}(${params}): ${returnType} {`]; + for (const bl of bodyLines) out.push(` ${bl}`); + out.push(" }"); + return out; +} diff --git a/apps/server/src/codegen/emitters/nestjs/controller.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/controller.emitter.spec.ts new file mode 100644 index 0000000..8f1a0a9 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/controller.emitter.spec.ts @@ -0,0 +1,608 @@ +import { describe, it, expect } from "vitest"; +import { emitController } from "./controller.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +const PROJECT = "00000000-0000-4000-8000-000000000000"; +const TAB = "22222222-2222-4222-8222-222222222222"; + +function node(type: StoredNode["type"], id: string, properties: Record): StoredNode { + return { + id, + type, + projectId: PROJECT, + positionX: 0, + positionY: 0, + homeTabId: TAB, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function edge(kind: StoredEdge["kind"], source: string, target: string, id: string): StoredEdge { + return { + id, + projectId: PROJECT, + sourceNodeId: source, + targetNodeId: target, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: false }, + }; +} + +function ctxFor(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { + return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; +} + +const CTRL_ID = "c1111111-1111-4111-8111-111111111111"; +const SVC_ID = "53333333-3333-4333-8333-333333333333"; +const CREATE_DTO_ID = "d4444444-4444-4444-8444-444444444444"; +const USER_DTO_ID = "d5555555-5555-4555-8555-555555555555"; + +const USERS_SERVICE = node("Service", SVC_ID, { + ServiceName: "UsersService", + Description: "User is mantigi", + IsTransactionScoped: false, + Methods: [{ MethodName: "create", Visibility: "public", Parameters: [], ReturnType: "User", IsAsync: true, Throws: [] }], + Dependencies: [], +}); + +const CREATE_USER_DTO = node("DTO", CREATE_DTO_ID, { + Name: "CreateUserDto", + Description: "Yeni kullanici girdisi", + Fields: [{ Name: "email", DataType: "string", IsRequired: true, IsArray: false }], +}); + +const USER_DTO = node("DTO", USER_DTO_ID, { + Name: "UserDto", + Description: "User ciktisi", + Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], +}); + +const USERS_CONTROLLER = node("Controller", CTRL_ID, { + ControllerName: "UsersController", + Description: "User HTTP yuzeyi", + BaseRoute: "users", + Version: "v1", + Endpoints: [ + { + HttpMethod: "GET", + Route: ":id", + RequiresAuth: true, + RequiredRoles: ["admin"], + PathParams: [{ Name: "id", Type: "string" }], + QueryParams: [{ Name: "expand", Type: "string", Required: false }], + StatusCodes: [{ Code: 200 }], + ResponseDTORef: "UserDto", + MiddlewareRefs: [], + }, + { + HttpMethod: "POST", + Route: "/", + RequiresAuth: false, + RequiredRoles: [], + PathParams: [], + QueryParams: [], + StatusCodes: [{ Code: 201 }], + RequestDTORef: "CreateUserDto", + ResponseDTORef: "UserDto", + MiddlewareRefs: [], + }, + ], +}); + +describe("emitController", () => { + it("tam controller — snapshot", () => { + const ctx = ctxFor([USERS_CONTROLLER, USERS_SERVICE, CREATE_USER_DTO, USER_DTO], [ + edge("CALLS", CTRL_ID, SVC_ID, "e1111111-1111-4111-8111-111111111111"), + ]); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { Body, Controller, Get, HttpCode, Param, Post, Query, UseGuards } from "@nestjs/common"; + import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; + import { type AuthUser, CurrentUser } from "../shared/decorators/current-user.decorator"; + import { Roles } from "../shared/decorators/roles.decorator"; + import { AuthGuard } from "../shared/guards/auth.guard"; + import { RolesGuard } from "../shared/guards/roles.guard"; + import { CreateUserDto } from "./dto/create-user.dto"; + import { UserDto } from "./dto/user.dto"; + import { UsersService } from "./users.service"; + + /** User HTTP yuzeyi */ + @ApiTags("UsersController") + @Controller("v1/users") + export class UsersController { + constructor( + private readonly usersService: UsersService + ) {} + + @Post() + @HttpCode(201) + @ApiOperation({ summary: "POST /" }) + @ApiResponse({ status: 201, type: UserDto }) + async post( + @Body() dto: CreateUserDto, + ): Promise { + // @solarch:surgical id=c1111111-1111-4111-8111-111111111111#post + // Handles the POST / endpoint. + // Delegation hint: this.usersService.(...). + // Input DTO: CreateUserDto. + // deps: usersService + throw new Error("NOT_IMPLEMENTED: UsersController.post"); + } + + @Get(":id") + @HttpCode(200) + @UseGuards(AuthGuard, RolesGuard) + @Roles("admin") + @ApiBearerAuth() + @ApiOperation({ summary: "GET :id" }) + @ApiResponse({ status: 200, type: UserDto }) + async getById( + @Param("id") id: string, + @CurrentUser() user: AuthUser, + @Query("expand") expand?: string, + ): Promise { + // @solarch:surgical id=c1111111-1111-4111-8111-111111111111#getById + // Handles the GET :id endpoint. + // Delegation hint: this.usersService.(...). + // Authenticated user available as 'user' (e.g. user.id). + // deps: usersService + throw new Error("NOT_IMPLEMENTED: UsersController.getById"); + } + } + ", + "language": "typescript", + "path": "users/users.controller.ts", + "surgicalMarkers": 2, + } + `); + }); + + it("govde-alan write endpoint'i RequestDTORef'siz -> genel @Body() body baglanir (fill serbest degisken UYDURMAZ)", () => { + // POST RequestDTORef WITHOUT: eskiden hic body param baglanmaz, surgical fill + // body alanlarini (productId/quantity) serbest degisken sanip TS2304 uretirdi. + // Genel `@Body() body: Record` bagla -> fill body.'den okur. + const ctrl = node("Controller", CTRL_ID, { + ControllerName: "CartController", + Description: "Sepet", + BaseRoute: "cart", + Endpoints: [ + { + HttpMethod: "POST", + Route: "items", + RequiresAuth: true, + RequiredRoles: [], + PathParams: [], + QueryParams: [], + StatusCodes: [{ Code: 200 }], + // RequestDTORef NONE + ResponseDTORef: "UserDto", + MiddlewareRefs: [], + }, + ], + }); + const ctx = ctxFor([ctrl, USERS_SERVICE, USER_DTO], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content).toContain("@Body() body: Record"); + expect(file.content).toContain('import { Body, Controller'); + // Fill hint body'nin tipsiz erisilebilir oldugunu soyler. + expect(file.content).toMatch(/body.*untyped|untyped.*body/i); + }); + + it("DI servisini CALLS edge'inden cozer ve import eder", () => { + const ctx = ctxFor([USERS_CONTROLLER, USERS_SERVICE, CREATE_USER_DTO, USER_DTO], [ + edge("CALLS", CTRL_ID, SVC_ID, "e1111111-1111-4111-8111-111111111111"), + ]); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content).toContain("private readonly usersService: UsersService"); + expect(file.content).toContain('import { UsersService } from "./users.service";'); + }); + + it("Version BaseRoute'a onek olur", () => { + const ctx = ctxFor([USERS_CONTROLLER, USERS_SERVICE], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content).toContain('@Controller("v1/users")'); + }); + + it("RequiresAuth -> @UseGuards(AuthGuard) + stub import", () => { + const ctx = ctxFor([USERS_CONTROLLER, USERS_SERVICE, USER_DTO], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + // USERS_CONTROLLER :id endpoint'i hem auth hem roles tasir -> @UseGuards(AuthGuard, RolesGuard). + expect(file.content).toMatch(/@UseGuards\(AuthGuard/); + expect(file.content).toContain('import { AuthGuard } from "../shared/guards/auth.guard";'); + }); + + /* ── RBAC WIRE (#39): @Roles route'una RolesGuard baglanir ─────────────── + * Eskiden @Roles metadata yaziliyordu ama hicbir guard okumuyordu (olu RBAC). + * Artik RequiredRoles olan endpoint @UseGuards'a RolesGuard ekler — Reflector ile + * ROLES_KEY metadata'sini okuyup enforce eden gercek guard (scaffold uretir). */ + it("RequiredRoles -> @UseGuards(AuthGuard, RolesGuard) + RolesGuard import + @Roles", () => { + const ctrl = node("Controller", CTRL_ID, { + ControllerName: "AdminController", + Description: "yonetim", + BaseRoute: "admin", + Endpoints: [ + { + HttpMethod: "POST", Route: "/", RequiresAuth: true, RequiredRoles: ["admin", "owner"], + PathParams: [], QueryParams: [], StatusCodes: [{ Code: 201 }], MiddlewareRefs: [], + }, + ], + }); + const ctx = ctxFor([ctrl], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content).toMatch(/@UseGuards\(AuthGuard, RolesGuard\)/); + expect(file.content).toContain('import { RolesGuard } from "../shared/guards/roles.guard";'); + expect(file.content).toContain('@Roles("admin", "owner")'); + }); + + it("her govde-gerektiren metotta surgical marker + NOT_IMPLEMENTED var", () => { + const ctx = ctxFor([USERS_CONTROLLER, USERS_SERVICE, CREATE_USER_DTO, USER_DTO], [ + edge("CALLS", CTRL_ID, SVC_ID, "e1111111-1111-4111-8111-111111111111"), + ]); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.surgicalMarkers).toBe(2); + expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: UsersController.getById");'); + expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: UsersController.post");'); + }); + + it("DETERMINISM: same graph twice -> byte-identical", () => { + const ctx = ctxFor([USERS_CONTROLLER, USERS_SERVICE, CREATE_USER_DTO, USER_DTO], [ + edge("CALLS", CTRL_ID, SVC_ID, "e1111111-1111-4111-8111-111111111111"), + ]); + const a = emitController(ctx.graph.byId(CTRL_ID)!, ctx)[0].content; + const b = emitController(ctx.graph.byId(CTRL_ID)!, ctx)[0].content; + expect(a).toBe(b); + }); + + it("content ends with single newline", () => { + const ctx = ctxFor([USERS_CONTROLLER, USERS_SERVICE], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("path/query param tipleri GECERLI TS'e normalize edilir (uuid/int/long -> string/number)", () => { + const typed = node("Controller", CTRL_ID, { + ControllerName: "ItemsController", + Description: "tipli paramlar", + BaseRoute: "items", + Endpoints: [ + { + HttpMethod: "GET", + Route: ":id", + RequiresAuth: false, + RequiredRoles: [], + PathParams: [{ Name: "id", Type: "uuid" }], + QueryParams: [ + { Name: "count", Type: "int", Required: false }, + { Name: "since", Type: "datetime", Required: false }, + ], + StatusCodes: [{ Code: 200 }], + MiddlewareRefs: [], + }, + ], + }); + const ctx = ctxFor([typed], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + // uuid -> string, int -> number, datetime -> Date (ham 'uuid'/'int' GECERSIZ TS idi). + expect(file.content).toContain('@Param("id") id: string'); + expect(file.content).toContain('@Query("count") count?: number'); + expect(file.content).toContain('@Query("since") since?: Date'); + expect(file.content).not.toContain(": uuid"); + expect(file.content).not.toContain(": int"); + }); + + /* ── EDGE-CASE: servissiz controller + kayip DTO ref + bos StatusCodes ── */ + it("CALLS edge yoksa constructor uretilmez; kayip DTO ref TODO birakir, throw etmez", () => { + const lonely = node("Controller", CTRL_ID, { + ControllerName: "PingController", + Description: "Saglik", + BaseRoute: "ping", + Endpoints: [ + { + HttpMethod: "POST", + Route: "/", + RequiresAuth: false, + RequiredRoles: [], + PathParams: [], + QueryParams: [], + StatusCodes: [], + RequestDTORef: "MissingDto", + MiddlewareRefs: [], + }, + ], + }); + const ctx = ctxFor([lonely], []); // hic service/DTO yok + expect(() => emitController(ctx.graph.byId(CTRL_ID)!, ctx)).not.toThrow(); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content).not.toContain("constructor("); + expect(file.content).toContain("@Body() dto: unknown"); + expect(file.content).toContain("MissingDto"); + // bos StatusCodes -> @HttpCode yok + expect(file.content).not.toContain("@HttpCode"); + // delegasyon ipucu yok (servis yok) + expect(file.content).not.toContain("Delegation hint"); + expect(file.path).toBe("ping/ping.controller.ts"); + }); + + /* ── Finding #6: ROUTE SIRASI — statik rota'lar ":param" rota'lardan FIRST ── */ + it("statik rota'lar param rota'lardan FIRST deklare edilir (Nest eslesme tuzagi)", () => { + const ctrl = node("Controller", CTRL_ID, { + ControllerName: "ProductsController", + Description: "urun API", + BaseRoute: "products", + Endpoints: [ + // Graph sirasi: once :id (param), AFTER categories (statik) -> Nest'te + // "/categories" asla eslesmezdi. Emitter bunu duzeltmeli. + { + HttpMethod: "GET", Route: ":id", RequiresAuth: false, RequiredRoles: [], + PathParams: [{ Name: "id", Type: "uuid" }], QueryParams: [], StatusCodes: [{ Code: 200 }], + ResponseDTORef: "ProductDto", MiddlewareRefs: [], + }, + { + HttpMethod: "GET", Route: "categories", RequiresAuth: false, RequiredRoles: [], + PathParams: [], QueryParams: [], StatusCodes: [{ Code: 200 }], MiddlewareRefs: [], + }, + ], + }); + const ctx = ctxFor([ctrl], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + const categoriesIdx = file.content.indexOf('@Get("categories")'); + const byIdIdx = file.content.indexOf('@Get(":id")'); + expect(categoriesIdx).toBeGreaterThanOrEqual(0); + expect(byIdIdx).toBeGreaterThanOrEqual(0); + // statik "categories" param ":id"'den FIRST cikmali. + expect(categoriesIdx).toBeLessThan(byIdIdx); + }); + + it("statik/param siralamasi STABLE — esit ranklarda graph sirasi korunur", () => { + const ctrl = node("Controller", CTRL_ID, { + ControllerName: "ItemsController", + Description: "stable", + BaseRoute: "items", + Endpoints: [ + { HttpMethod: "GET", Route: "featured", RequiresAuth: false, RequiredRoles: [], PathParams: [], QueryParams: [], StatusCodes: [], MiddlewareRefs: [] }, + { HttpMethod: "GET", Route: ":id", RequiresAuth: false, RequiredRoles: [], PathParams: [{ Name: "id", Type: "string" }], QueryParams: [], StatusCodes: [], MiddlewareRefs: [] }, + { HttpMethod: "GET", Route: "popular", RequiresAuth: false, RequiredRoles: [], PathParams: [], QueryParams: [], StatusCodes: [], MiddlewareRefs: [] }, + ], + }); + const ctx = ctxFor([ctrl], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + const featured = file.content.indexOf('@Get("featured")'); + const popular = file.content.indexOf('@Get("popular")'); + const byId = file.content.indexOf('@Get(":id")'); + // iki statik graph sirasinda (featured < popular), ikisi de param'dan once. + expect(featured).toBeLessThan(popular); + expect(popular).toBeLessThan(byId); + }); + + /* ── Finding #7: LIST RETURN — koleksiyon endpoint'i DTO[] doner ── */ + it("GET + path-param NONE -> koleksiyon: ResponseDTORef DTO[] doner", () => { + const ctrl = node("Controller", CTRL_ID, { + ControllerName: "ProductsController", + Description: "liste", + BaseRoute: "products", + Endpoints: [ + // GET /, no path param -> koleksiyon -> ProductDto[]. + { HttpMethod: "GET", Route: "/", RequiresAuth: false, RequiredRoles: [], PathParams: [], QueryParams: [], StatusCodes: [{ Code: 200 }], ResponseDTORef: "ProductDto", MiddlewareRefs: [] }, + // GET /:id -> tekil kayit -> ProductDto (array NOT). + { HttpMethod: "GET", Route: ":id", RequiresAuth: false, RequiredRoles: [], PathParams: [{ Name: "id", Type: "string" }], QueryParams: [], StatusCodes: [{ Code: 200 }], ResponseDTORef: "ProductDto", MiddlewareRefs: [] }, + ], + }); + const productDto = node("DTO", "d6666666-6666-4666-8666-666666666666", { + Name: "ProductDto", Description: "urun", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], + }); + const ctx = ctxFor([ctrl, productDto], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content).toContain("async get(): Promise"); + // getById tekil doner (array NOT). + expect(file.content).toMatch(/async getById\([\s\S]*?\): Promise \{/); + }); + + it("GET /me (self/tekil semantik) -> path-param olmasa da SINGLE DTO (array NOT)", () => { + const ctrl = node("Controller", CTRL_ID, { + ControllerName: "AccountController", + Description: "self", + BaseRoute: "account", + Endpoints: [ + { HttpMethod: "GET", Route: "me", RequiresAuth: true, RequiredRoles: [], PathParams: [], QueryParams: [], StatusCodes: [{ Code: 200 }], ResponseDTORef: "UserDto", MiddlewareRefs: [] }, + ], + }); + const ctx = ctxFor([ctrl, USER_DTO], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content).toContain("Promise"); + expect(file.content).not.toContain("Promise"); + }); + + it("route list/findAll semantigi -> path-param olsa bile koleksiyon (DTO[])", () => { + const ctrl = node("Controller", CTRL_ID, { + ControllerName: "OrdersController", + Description: "liste-semantigi", + BaseRoute: "orders", + Endpoints: [ + // GET /:userId/list -> path param var ama son segment "list" -> koleksiyon. + { HttpMethod: "GET", Route: ":userId/list", RequiresAuth: false, RequiredRoles: [], PathParams: [{ Name: "userId", Type: "string" }], QueryParams: [], StatusCodes: [], ResponseDTORef: "OrderDto", MiddlewareRefs: [] }, + ], + }); + const orderDto = node("DTO", "d7777777-7777-4777-8777-777777777777", { + Name: "OrderDto", Description: "order", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], + }); + const ctx = ctxFor([ctrl, orderDto], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content).toContain("Promise"); + }); + + /* ── TEK-SOURCE KARDINALITE: bildirilmis ReturnsCollection > route sezgisi ── + * Endpoint'te ReturnsCollection bildirilmisse, controller route sekline DAYALI + * tahmini (isCollectionEndpoint) NOT bildirilen alani kullanir. service.emitter + * ile AYNI tek-kaynak: kanvas iki uca da ayni karari set eder -> imzalar garantili + * hizali. */ + it("ReturnsCollection=true: route tekil-sezgili (GET /:id) olsa bile DTO[] doner", () => { + const ctrl = node("Controller", CTRL_ID, { + ControllerName: "ProductsController", + Description: "bildirilmis koleksiyon", + BaseRoute: "products", + Endpoints: [ + // GET /:id -> route sezgisi SINGLE der (path param var); ama bildirilen true. + { + HttpMethod: "GET", Route: ":id", RequiresAuth: false, RequiredRoles: [], + PathParams: [{ Name: "id", Type: "string" }], QueryParams: [], StatusCodes: [{ Code: 200 }], + ResponseDTORef: "ProductDto", ReturnsCollection: true, MiddlewareRefs: [], + }, + ], + }); + const productDto = node("DTO", "d9999999-9999-4999-8999-999999999999", { + Name: "ProductDto", Description: "urun", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], + }); + const ctx = ctxFor([ctrl, productDto], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content).toContain("Promise"); + expect(file.content).not.toContain("Promise {"); + }); + + it("ReturnsCollection=false: route koleksiyon-sezgili (GET /) olsa bile SINGLE doner", () => { + const ctrl = node("Controller", CTRL_ID, { + ControllerName: "SummaryController", + Description: "bildirilmis tekil", + BaseRoute: "summary", + Endpoints: [ + // GET / (path-param yok) -> route sezgisi COLLECTION der; ama bildirilen false. + { + HttpMethod: "GET", Route: "/", RequiresAuth: false, RequiredRoles: [], + PathParams: [], QueryParams: [], StatusCodes: [{ Code: 200 }], + ResponseDTORef: "ProductDto", ReturnsCollection: false, MiddlewareRefs: [], + }, + ], + }); + const productDto = node("DTO", "da999999-9999-4999-8999-999999999999", { + Name: "ProductDto", Description: "urun", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], + }); + const ctx = ctxFor([ctrl, productDto], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content).toMatch(/\): Promise \{/); + expect(file.content).not.toContain("Promise"); + }); + + /* ── Finding #8: AUTH/userId + login token ── */ + it("RequiresAuth -> @CurrentUser() user: AuthUser parametresi + import", () => { + const ctrl = node("Controller", CTRL_ID, { + ControllerName: "MeController", + Description: "kimlik", + BaseRoute: "me", + Endpoints: [ + { HttpMethod: "GET", Route: "/", RequiresAuth: true, RequiredRoles: [], PathParams: [], QueryParams: [], StatusCodes: [{ Code: 200 }], ResponseDTORef: "UserDto", MiddlewareRefs: [] }, + ], + }); + const ctx = ctxFor([ctrl, USER_DTO], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content).toContain("@CurrentUser() user: AuthUser"); + expect(file.content).toContain('import { type AuthUser, CurrentUser } from "../shared/decorators/current-user.decorator";'); + // userId surgical govdede erisilebilir oldugu marker'da belirtilir. + expect(file.content).toContain("Authenticated user available as 'user' (e.g. user.id)."); + }); + + it("RequiresAuth=false -> @CurrentUser NONE", () => { + const ctrl = node("Controller", CTRL_ID, { + ControllerName: "PublicController", + Description: "acik", + BaseRoute: "public", + Endpoints: [ + { HttpMethod: "GET", Route: "ping", RequiresAuth: false, RequiredRoles: [], PathParams: [], QueryParams: [], StatusCodes: [], MiddlewareRefs: [] }, + ], + }); + const ctx = ctxFor([ctrl], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content).not.toContain("@CurrentUser"); + expect(file.content).not.toContain("AuthUser"); + }); + + it("login endpoint (ResponseDTORef yok) -> Promise (void degil)", () => { + const ctrl = node("Controller", CTRL_ID, { + ControllerName: "AuthController", + Description: "auth", + BaseRoute: "auth", + Endpoints: [ + { HttpMethod: "POST", Route: "login", RequestDTORef: "LoginDto", RequiresAuth: false, RequiredRoles: [], PathParams: [], QueryParams: [], StatusCodes: [{ Code: 200 }], MiddlewareRefs: [] }, + ], + }); + const loginDto = node("DTO", "d8888888-8888-4888-8888-888888888888", { + Name: "LoginDto", Description: "giris", Fields: [{ Name: "email", DataType: "string", IsRequired: true, IsArray: false }], + }); + const ctx = ctxFor([ctrl, loginDto], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content).toContain("Promise"); + expect(file.content).toContain('import type { AuthResponse } from "../shared/decorators/current-user.decorator";'); + expect(file.content).not.toContain("): Promise"); + }); + + /* ── Task 6: @nestjs/swagger decorators (self-documenting generated app) ── + * Generated controllers now carry OpenAPI decorators: @ApiTags on the class, + * @ApiOperation + @ApiResponse per endpoint, @ApiBearerAuth when RequiresAuth. + * The response DTO is referenced as a RUNTIME value (`type: Dto`) so its import + * must be a value import (not `import type`). */ + it("emits @nestjs/swagger decorators: @ApiTags on class + @ApiOperation/@ApiResponse per endpoint + @ApiBearerAuth on auth", () => { + const ctx = ctxFor([USERS_CONTROLLER, USERS_SERVICE, CREATE_USER_DTO, USER_DTO], [ + edge("CALLS", CTRL_ID, SVC_ID, "e1111111-1111-4111-8111-111111111111"), + ]); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + // class-level API group tag + expect(file.content).toContain('@ApiTags("UsersController")'); + // per-endpoint operation + response decorators + expect(file.content).toContain("@ApiOperation("); + expect(file.content).toContain("@ApiResponse("); + // GET :id is RequiresAuth=true -> bearer marker present + expect(file.content).toContain("@ApiBearerAuth()"); + // swagger symbols imported. ImportCollector sorts symbols alphabetically, so + // assert membership (order-agnostic) rather than a fixed symbol order. + const swagger = file.content.match(/import \{([^}]*)\} from "@nestjs\/swagger";/); + expect(swagger, "expected a @nestjs/swagger import line").toBeTruthy(); + const symbols = swagger![1]; + for (const sym of ["ApiTags", "ApiOperation", "ApiResponse", "ApiBearerAuth"]) { + expect(symbols).toContain(sym); + } + // @ApiResponse references the resolved response DTO as a runtime type reference. + expect(file.content).toMatch(/@ApiResponse\(\{ status: 201[^}]*type: UserDto/); + // ...so the response DTO is a VALUE import, not `import type`. + expect(file.content).toContain('import { UserDto } from "./dto/user.dto";'); + }); + + it("public endpoint (RequiresAuth=false) gets @ApiOperation but no @ApiBearerAuth", () => { + const ctrl = node("Controller", CTRL_ID, { + ControllerName: "PublicController", + Description: "public surface", + BaseRoute: "public", + Endpoints: [ + { HttpMethod: "GET", Route: "ping", RequiresAuth: false, RequiredRoles: [], PathParams: [], QueryParams: [], StatusCodes: [{ Code: 200 }], MiddlewareRefs: [] }, + ], + }); + const ctx = ctxFor([ctrl], []); + const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); + expect(file.content).toContain("@ApiOperation("); + expect(file.content).not.toContain("@ApiBearerAuth"); + }); + + it("DETERMINISM: route-sira + auth + array fix sonrasi byte-identical", () => { + const ctrl = node("Controller", CTRL_ID, { + ControllerName: "MixController", + Description: "karisik", + BaseRoute: "mix", + Endpoints: [ + { HttpMethod: "GET", Route: ":id", RequiresAuth: true, RequiredRoles: [], PathParams: [{ Name: "id", Type: "string" }], QueryParams: [], StatusCodes: [], ResponseDTORef: "UserDto", MiddlewareRefs: [] }, + { HttpMethod: "GET", Route: "/", RequiresAuth: false, RequiredRoles: [], PathParams: [], QueryParams: [], StatusCodes: [], ResponseDTORef: "UserDto", MiddlewareRefs: [] }, + ], + }); + const ctx = ctxFor([ctrl, USER_DTO], []); + const a = emitController(ctx.graph.byId(CTRL_ID)!, ctx)[0].content; + const b = emitController(ctx.graph.byId(CTRL_ID)!, ctx)[0].content; + expect(a).toBe(b); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/controller.emitter.ts b/apps/server/src/codegen/emitters/nestjs/controller.emitter.ts new file mode 100644 index 0000000..1ad5494 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/controller.emitter.ts @@ -0,0 +1,542 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import { propsOf, type CodeNode } from "../../ir"; +import { + camelCase, + filePathFor, + importPathOf, + pascalCase, + relativeImportPath, + scalarTsType, + splitWords, +} from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers, notImplemented, surgicalMarker } from "../../surgical"; +import { tokensHaveCollectionSemantics } from "../../cardinality"; + +/* ──────────────────────────────────────────────────────────────────────── + * controller.emitter.ts — ControllerNode -> /.controller.ts. + * + * @Controller(BaseRoute (+ Version oneki)). DI = ctx.outEdges(id, "CALLS") + * -> Service(ler), constructor injection (private readonly). Her Endpoint -> + * dekoratorlu metot: + * - HTTP fiili -> @Get/@Post/@Put/@Delete/@Patch(Route) + * - ilk StatusCode -> @HttpCode(code) + * - RequiresAuth -> @UseGuards(AuthGuard) (shared/ stub guard importu) + * - RequiredRoles -> @Roles(...) (shared/ stub decorator importu) + * - PathParams -> @Param("name") name: Type + * - QueryParams -> @Query("name") name: Type + * - RequestDTORef -> @Body() dto: (ref cozulurse import + tip) + * - ResponseDTORef -> Promise (ref cozulurse import + tip) + * Metot adi HttpMethod + Route + path param'lardan DETERMINISTIC turetilir + * (or. GET /users/:id -> getUserById). Govde: surgicalMarker + NOT_IMPLEMENTED; + * delegasyon ipucu (this..) marker aciklamasinda verilir. + * + * SAF + DETERMINISTIC: koleksiyonlar sirali, kayip ref tolere edilir (THROW yok), + * import'lar ImportCollector ile, icerik tek "\n" ile biter. + * ──────────────────────────────────────────────────────────────────────── */ + +type EndpointProps = ReturnType>["Endpoints"][number]; + +const HTTP_DECORATOR: Record = { + GET: "Get", + POST: "Post", + PUT: "Put", + DELETE: "Delete", + PATCH: "Patch", +}; + +/** Istek govdesi (body) bekleyebilen HTTP fiilleri (GET/DELETE govdesizdir). */ +const WRITE_METHODS: ReadonlySet = new Set(["POST", "PUT", "PATCH"]); + +export const emitController: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = propsOf<"Controller">(node); + const className = pascalCase(node.name); + const thisFile = filePathFor(node, ctx.graph); + + const imports = new ImportCollector(); + + // ── @Controller route: Version onekini SADECE BaseRoute zaten icermiyorsa + // ekle. BaseRoute tam yolu tasiyorsa (or. "api/v1/auth") tekrar onekleme + // -> "v1/api/v1/auth" gibi DOUBLE prefix OLUSMAZ. ── + const baseRoute = normalizeRoute(props.BaseRoute); + const controllerRoute = computeControllerRoute(props.Version, baseRoute); + + // @nestjs/common cekirdek dekoratorleri (kullanilanlar kosullu eklenir). + imports.add("Controller", "@nestjs/common"); + // @nestjs/swagger: sinif @ApiTags ile bir OpenAPI grubu olarak etiketlenir + // (uretilen uygulama kendini Scalar /docs altinda belgeler). + imports.add("ApiTags", "@nestjs/swagger"); + + // ── DI: CALLS edge'lerinden Service'ler (edge'ler isme gore sirali) ── + const services = collectInjectedServices(node, ctx); + for (const svc of services) { + imports.add(svc.className, relativeImportPath(thisFile, importPathOf(svc.file))); + } + + // ── Endpoint metotlari ── + // ROUTE SIRASI (Finding #6): NestJS rota'lari DEKLARASYON SIRASIYLA eslestirir. + // Ayni HTTP fiilinde STATIC rota'lar ("categories") PARAM rota'lardan (":id") + // FIRST gelmeli — yoksa "/categories" hicbir zaman eslesmez (":id" once yakalar). + // sortEndpointsForRouting: statik-segmentli endpoint'ler once, ":param" icerenler + // sonra; esitlikte mevcut sira KORUNUR (stable, deterministik). + const orderedEndpoints = sortEndpointsForRouting(props.Endpoints); + const methodBlocks: string[] = []; + for (const ep of orderedEndpoints) { + methodBlocks.push(buildEndpoint(node, ep, services, imports, thisFile, ctx, className)); + } + + // ── Sinif govdesi ── + const lines: string[] = []; + if (props.Description) lines.push(`/** ${props.Description} */`); + lines.push(`@ApiTags(${JSON.stringify(node.name)})`); + lines.push(`@Controller(${JSON.stringify(controllerRoute)})`); + lines.push(`export class ${className} {`); + + // constructor (yalniz servis varsa) + if (services.length > 0) { + lines.push(" constructor("); + services.forEach((svc, i) => { + const comma = i < services.length - 1 ? "," : ""; + lines.push(` private readonly ${svc.field}: ${svc.className}${comma}`); + }); + lines.push(" ) {}"); + lines.push(""); + } + + methodBlocks.forEach((block, i) => { + lines.push(block); + if (i < methodBlocks.length - 1) lines.push(""); + }); + + lines.push("}"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: thisFile, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/* ── DI: CALLS -> Service cozumleme ──────────────────────────────────────── */ +interface InjectedService { + name: string; + className: string; + field: string; + file: string; +} + +function collectInjectedServices(node: CodeNode, ctx: EmitterContext): InjectedService[] { + const seen = new Set(); + const out: InjectedService[] = []; + // outEdges zaten kind,source.name,target.name,id'ye gore sirali (deterministik). + for (const e of ctx.graph.outEdges(node.id, "CALLS")) { + const tgt = ctx.graph.byId(e.targetNodeId); + if (!tgt || tgt.kindOf() !== "Service") continue; // kayip/yanlis ref -> atla + if (seen.has(tgt.id)) continue; + seen.add(tgt.id); + const cls = pascalCase(tgt.name); + out.push({ + name: tgt.name, + className: cls, + field: camelCase(tgt.name), + file: filePathFor(tgt, ctx.graph), + }); + } + return out; +} + +/* ── Tek endpoint -> metot bloku ─────────────────────────────────────────── */ +function buildEndpoint( + node: CodeNode, + ep: EndpointProps, + services: InjectedService[], + imports: ImportCollector, + thisFile: string, + ctx: EmitterContext, + className: string, +): string { + const decoratorLines: string[] = []; + + // HTTP fiili dekoratoru + const httpDecorator = HTTP_DECORATOR[ep.HttpMethod] ?? "Get"; + imports.add(httpDecorator, "@nestjs/common"); + const routeArg = methodRouteArg(ep.Route); + decoratorLines.push(` @${httpDecorator}(${routeArg})`); + + // Ilk StatusCode -> @HttpCode + const firstCode = ep.StatusCodes.length > 0 ? ep.StatusCodes[0].Code : undefined; + if (firstCode !== undefined) { + imports.add("HttpCode", "@nestjs/common"); + decoratorLines.push(` @HttpCode(${firstCode})`); + } + + // RequiresAuth/RequiredRoles -> @UseGuards(AuthGuard[, RolesGuard]). AuthGuard + // (authentication) request.user'i yerlestirir; RolesGuard (authorization, #39) + // @Roles metadata'sini Reflector ile okuyup enforce eder. SIRA onemli: AuthGuard + // FIRST (user'i set eder), RolesGuard AFTER (role'u okur). Eskiden RolesGuard hic + // baglanmiyordu -> @Roles oluydu. + const guards: string[] = []; + if (ep.RequiresAuth) { + imports.add("AuthGuard", relativeImportPath(thisFile, "shared/guards/auth.guard")); + guards.push("AuthGuard"); + } + if (ep.RequiredRoles.length > 0) { + imports.add("RolesGuard", relativeImportPath(thisFile, "shared/guards/roles.guard")); + guards.push("RolesGuard"); + } + if (guards.length > 0) { + imports.add("UseGuards", "@nestjs/common"); + decoratorLines.push(` @UseGuards(${guards.join(", ")})`); + } + + // RequiredRoles -> @Roles(...) (RolesGuard bu ROLES_KEY metadata'sini okur) + if (ep.RequiredRoles.length > 0) { + imports.add("Roles", relativeImportPath(thisFile, "shared/decorators/roles.decorator")); + const roleArgs = ep.RequiredRoles.map((r) => JSON.stringify(r)).join(", "); + decoratorLines.push(` @Roles(${roleArgs})`); + } + + // ── Parametreler ── + const params: string[] = []; + + // PathParams -> @Param("name") name: Type. (Paramsiz endpoint'lerde alan hic + // gelmeyebilir — graf verisi eksik olsa da emitter patlamamali: bos diziye dus.) + for (const p of ep.PathParams ?? []) { + imports.add("Param", "@nestjs/common"); + params.push(`@Param(${JSON.stringify(p.Name)}) ${safeIdent(p.Name)}: ${tsType(p.Type)}`); + } + + // QueryParams -> @Query("name") name: Type. (Ayni savunma: alan eksikse bos dizi.) + for (const q of ep.QueryParams ?? []) { + imports.add("Query", "@nestjs/common"); + const optional = q.Required ? "" : "?"; + params.push(`@Query(${JSON.stringify(q.Name)}) ${safeIdent(q.Name)}${optional}: ${tsType(q.Type)}`); + } + + // RequestDTORef -> @Body() dto: + let bodyDtoClass: string | null = null; + let injectsRawBody = false; + if (ep.RequestDTORef) { + imports.add("Body", "@nestjs/common"); + const dto = ctx.graph.resolveRef("DTO", ep.RequestDTORef); + if (dto) { + bodyDtoClass = pascalCase(dto.name); + imports.add(bodyDtoClass, relativeImportPath(thisFile, importPathOf(filePathFor(dto, ctx.graph)))); + params.push(`@Body() dto: ${bodyDtoClass}`); + } else { + // Kayip ref: tipsiz body (THROW yok), TODO birak. + params.push(`@Body() dto: unknown /* TODO: DTO '${ep.RequestDTORef}' not found */`); + } + } else if (WRITE_METHODS.has(ep.HttpMethod)) { + // Govde-alan write endpoint'i (POST/PUT/PATCH) RequestDTORef WITHOUT: tipli DTO yok. + // Eskiden hic body param baglanmazdi -> surgical fill body alanlarini (or. productId, + // quantity) SERBEST VARIABLE sanip `this.svc.x(productId, quantity)` uretip TS2304 + // veriyordu. Genel `@Body() body: Record` bagla: fill gercek (tipsiz) + // body'den okur (`body.productId` -> unknown, derlenir) — uydurma degisken uretmez. + // (Kontrat boslugu contract-lint Rule 1 ile ayrica uyarilir.) + imports.add("Body", "@nestjs/common"); + params.push(`@Body() body: Record`); + injectsRawBody = true; + } + + // ── REQUEST BAGLAMI / userId (Finding #8): RequiresAuth olan endpoint'lere + // @CurrentUser() user: AuthUser parametresi ekle. Boylece kimligi dogrulanmis + // kullanicinin id'si (user.id) surgical govdede ERISILEBILIR olur — imzada + // gectigi icin body icinden okunabilir. @CurrentUser, paylasimli bir param + // decorator'dur (shared/decorators/current-user.decorator); request.user'i + // cozer (AuthGuard onu yerlestirir). Param SON sirada gelir (decorator'lu + // @Param/@Query/@Body'den sonra) — okunakli + deterministik. ── + let injectsCurrentUser = false; + if (ep.RequiresAuth) { + injectsCurrentUser = true; + imports.add("CurrentUser", relativeImportPath(thisFile, "shared/decorators/current-user.decorator")); + imports.addType("AuthUser", relativeImportPath(thisFile, "shared/decorators/current-user.decorator")); + params.push(`@CurrentUser() user: AuthUser`); + } + + // ── Donus tipi ── + // ResponseDTORef -> Promise. LIST RETURN (Finding #7): koleksiyon + // donduren endpoint (GET + path-param NONE, ya da list/findAll/search/all + // semantigi) tekil DTO degil DTO[] doner. + // AUTH/LOGIN (Finding #8): ResponseDTORef WITHOUT bir login endpoint'i tutarli + // bir token zarfi (AuthResponse) doner — void degil. + let returnInner = "void"; + // Cozulen yanit DTO'su: @ApiResponse({ type: ... }) calisma-zamani (value) referansi + // icin sinif adi + import yolu burada yakalanir. + let responseDtoClass: string | null = null; + let responseDtoImport: string | null = null; + const collection = isCollectionEndpoint(ep); + if (ep.ResponseDTORef) { + const dto = ctx.graph.resolveRef("DTO", ep.ResponseDTORef); + if (dto) { + const dtoClass = pascalCase(dto.name); + const dtoImport = relativeImportPath(thisFile, importPathOf(filePathFor(dto, ctx.graph))); + imports.addType(dtoClass, dtoImport); + returnInner = collection ? `${dtoClass}[]` : dtoClass; + responseDtoClass = dtoClass; + responseDtoImport = dtoImport; + } else { + returnInner = `unknown /* TODO: DTO '${ep.ResponseDTORef}' not found */`; + } + } else if (isLoginEndpoint(ep)) { + // Login -> token: tutarli bir kimlik-dogrulama yaniti (accessToken tasir). + imports.addType("AuthResponse", relativeImportPath(thisFile, "shared/decorators/current-user.decorator")); + returnInner = "AuthResponse"; + } + const returnType = `Promise<${returnInner}>`; + + // ── @nestjs/swagger dekoratorleri (kendini-belgeleyen uretilmis uygulama) ── + // @ApiBearerAuth() RequiresAuth oldugunda; @ApiOperation({ summary }) her + // endpoint'te; her StatusCode icin bir @ApiResponse. Yanit DTO'su <400 kodlarda + // `type:` ile referanslanir (collection ise isArray:true). Aciklama/ornekler + // varsa zenginlestirilmis doc'tan gelir; burada DETERMINISTIC ozet kullanilir. + if (ep.RequiresAuth) { + imports.add("ApiBearerAuth", "@nestjs/swagger"); + decoratorLines.push(` @ApiBearerAuth()`); + } + imports.add("ApiOperation", "@nestjs/swagger"); + const summary = ep.Description ?? `${ep.HttpMethod} ${ep.Route}`; + decoratorLines.push(` @ApiOperation({ summary: ${JSON.stringify(summary)} })`); + imports.add("ApiResponse", "@nestjs/swagger"); + const responseCodes = ep.StatusCodes.length > 0 + ? ep.StatusCodes + : [{ Code: ep.HttpMethod === "POST" ? 201 : 200, Description: "OK" }]; + for (const sc of responseCodes) { + const parts: string[] = [`status: ${sc.Code}`]; + if (sc.Description) parts.push(`description: ${JSON.stringify(sc.Description)}`); + if (responseDtoClass && responseDtoImport && sc.Code < 400) { + // @ApiResponse({ type: Dto }) DTO'yu calisma-zamani DEGER'i olarak kullanir + // -> type-only import yerine deger importuna yukselt (yoksa derlenmez). + imports.add(responseDtoClass, responseDtoImport); + parts.push(`type: ${responseDtoClass}`); + if (collection) parts.push(`isArray: true`); + } + decoratorLines.push(` @ApiResponse({ ${parts.join(", ")} })`); + } + + // ── Metot adi: HTTP fiili + route + path param ── + const methodName = deriveMethodName(ep); + + // ── Govde: surgical marker + NOT_IMPLEMENTED ── + const delegate = services.length > 0 ? services[0].field : undefined; + const descParts: string[] = []; + if (ep.Description) descParts.push(ep.Description); + descParts.push(`Handles the ${ep.HttpMethod} ${ep.Route} endpoint.`); + if (delegate) descParts.push(`Delegation hint: this.${delegate}.(...).`); + if (bodyDtoClass) descParts.push(`Input DTO: ${bodyDtoClass}.`); + if (injectsRawBody) descParts.push(`Request body available (untyped) as 'body' — read fields via body. (no typed DTO).`); + if (injectsCurrentUser) descParts.push(`Authenticated user available as 'user' (e.g. user.id).`); + if (collection && ep.ResponseDTORef) descParts.push(`Returns a collection (array).`); + + const marker = surgicalMarker({ + nodeId: node.id, + member: methodName, + description: descParts.join("\n"), + throws: undefined, + deps: services.length > 0 ? services.map((s) => s.field) : undefined, + }); + + // TS: opsiyonel (`name?: T`) parametre, zorunlu parametreden FIRST gelemez (TS1016). + // @Query opsiyonel olabilir ama @CurrentUser/@Param/@Body zorunlu — opsiyonelleri + // sona, goreli sirayi koruyarak tasi (decorator baglamayi bozmaz: konum degil + // decorator deger atar; surgical govde param'lari ada gore okur). + const orderedParams = [ + ...params.filter((p) => !/\?:/.test(p)), + ...params.filter((p) => /\?:/.test(p)), + ]; + const paramList = orderedParams.length > 0 ? `\n ${orderedParams.join(",\n ")},\n ` : ""; + + const block: string[] = []; + decoratorLines.forEach((d) => block.push(d)); + block.push(` async ${methodName}(${paramList}): ${returnType} {`); + for (const line of marker.split("\n")) block.push(` ${line}`); + block.push(` ${notImplemented(className, methodName)}`); + block.push(` }`); + return block.join("\n"); +} + +/* ── Route sirasi / koleksiyon / login sezgileri (DETERMINISTIC) ──────────── */ + +/** ROUTE SIRASI (Finding #6): NestJS rota'lari @Controller icindeki DEKLARASYON + * sirasiyla eslestirir. ":param" iceren bir rota, kendinden sonra gelen STATIC + * bir rota'yi (ayni fiilde) golgeler — or. @Get(":id") @Get("categories")'ten + * once gelirse "/categories" hicbir zaman calismaz. + * + * Bu yuzden endpoint'leri STABLE bicimde yeniden siralariz: ":param"/"{param}" + * segmenti WITHOUT (statik) endpoint'ler FIRST, param icerenler AFTER. Esitlikte + * (ikisi de statik ya da ikisi de param) MEVCUT SIRA korunur (kullanici niyeti + + * determinizm). HTTP fiili karistirilmaz: param-iceren GET, statik POST'tan + * sonraya kayabilir ama bu Nest eslesmesini bozmaz (her fiil kendi icinde sirali + * kalir ve statik-once kurali tum fiiller icin guvenlidir). + * + * Stable sort: her endpoint'i orijinal index'iyle etiketle, anahtar (statik=0, + * param=1) esitse index'le kir. */ +function sortEndpointsForRouting(endpoints: readonly EndpointProps[]): EndpointProps[] { + return endpoints + .map((ep, index) => ({ ep, index, paramRank: hasRouteParam(ep.Route) ? 1 : 0 })) + .sort((a, b) => (a.paramRank !== b.paramRank ? a.paramRank - b.paramRank : a.index - b.index)) + .map((x) => x.ep); +} + +/** Bir route en az bir ":param" veya "{param}" segmenti iceriyor mu? */ +function hasRouteParam(route: string): boolean { + return route + .split("/") + .filter((s) => s.length > 0) + .some((seg) => seg.startsWith(":") || (seg.startsWith("{") && seg.endsWith("}"))); +} + +/** LIST RETURN (Finding #7): endpoint bir COLLECTION mu donduruyor? + * Kurallar (deterministik, endpoint SEKLINDEN): + * - Route'un son literal segmenti list/findAll/all/search/findMany semantigi -> koleksiyon + * (path-param olsa bile, or. /:userId/list). + * - GET + hic path-param NONE (ne PathParams ne de route'ta ":param") -> koleksiyon + * (klasik REST liste: GET /products) — ANCAK son literal segment tekil/self + * semantigi (me/current/profile/...) ise SINGLE (GET /me bir kayit doner). + * PathParams olan bir GET (or. /:id) SINGLE kaydi doner -> koleksiyon NOT. */ +function isCollectionEndpoint(ep: EndpointProps): boolean { + // TEK-SOURCE: bildirilmis ReturnsCollection (true/false) route sezgisini OVERRIDES. + // service.emitter ile ayni alan -> controller ve service imzalari garantili hizali. + if (typeof ep.ReturnsCollection === "boolean") return ep.ReturnsCollection; + if (ep.HttpMethod !== "GET") return false; + // Acik liste-semantigi her zaman koleksiyon (path-param fark etmez). + if (routeHasListSemantics(ep.Route)) return true; + const hasPathParam = (ep.PathParams?.length ?? 0) > 0 || hasRouteParam(ep.Route); + if (hasPathParam) return false; + // path-param yok: tekil/self semantigi tasimadikca koleksiyon (REST liste). + return !routeHasSingularSemantics(ep.Route); +} + +/** Route'un son STATIC segmenti list/findAll/all/search/findMany gibi bir + * koleksiyon-semantigi tasiyor mu? (camelCase/kebab/snake bolunur.) Kelime kumesi + * cardinality.ts'te TEK SOURCE — service.emitter metot adi icin aynisini kullanir. */ +function routeHasListSemantics(route: string): boolean { + const last = lastLiteralSegment(route); + if (!last) return false; + return tokensHaveCollectionSemantics(splitWords(last)); +} + +/** Route'un son STATIC segmenti SINGLE/self bir kaynak mi? (me/self/current/ + * profile/account/health/status/info/ping...) Bunlar path-param olmasa da + * tek bir kayit doner -> koleksiyon SAYILMAZ. */ +function routeHasSingularSemantics(route: string): boolean { + const last = lastLiteralSegment(route); + if (!last) return false; + const joined = splitWords(last).map((w) => w.toLowerCase()).join(""); + const SINGULAR_WORDS = new Set([ + "me", "self", "current", "profile", "account", "health", "status", "info", "ping", + ]); + return SINGULAR_WORDS.has(joined); +} + +/** Route'un son STATIC (param WITHOUT) segmenti; yoksa undefined. */ +function lastLiteralSegment(route: string): string | undefined { + const segments = route + .split("/") + .filter((s) => s.length > 0 && !s.startsWith(":") && !(s.startsWith("{") && s.endsWith("}"))); + return segments[segments.length - 1]; +} + +/** AUTH/LOGIN (Finding #8): ResponseDTORef WITHOUT bir login endpoint'i mi? + * (POST + route literal'lerinden biri "login"/"signin"/"authenticate".) Boyle + * bir endpoint void degil tutarli bir token zarfi (AuthResponse) doner. + * EXPORTED: scaffold.emitter ayni kosulu kullanarak current-user.decorator + * dosyasini (AuthResponse'u tutan) emit edip etmeyecegine karar verir. */ +export function isLoginEndpoint(ep: EndpointProps): boolean { + if (ep.HttpMethod !== "POST") return false; + const segments = ep.Route.split("/").filter((s) => s.length > 0 && !s.startsWith(":") && !s.startsWith("{")); + const LOGIN_WORDS = new Set(["login", "signin", "authenticate", "token"]); + for (const seg of segments) { + const joined = splitWords(seg).map((w) => w.toLowerCase()).join(""); + if (LOGIN_WORDS.has(joined)) return true; + } + return false; +} + +/* ── Isim/route yardimcilari (DETERMINISTIC) ─────────────────────────────── */ + +/** Metot adi: GET /users/:id -> getUserById; POST /users -> postUser. + * Fiil + route segmentleri (literal -> Pascal; ":param" -> "By Param"). */ +function deriveMethodName(ep: EndpointProps): string { + const verb = ep.HttpMethod.toLowerCase(); + const segments = ep.Route.split("/").filter((s) => s.length > 0); + const words: string[] = []; + for (const seg of segments) { + if (seg.startsWith(":")) { + words.push("By"); + words.push(...splitWords(seg.slice(1)).map(cap)); + } else if (seg.startsWith("{") && seg.endsWith("}")) { + words.push("By"); + words.push(...splitWords(seg.slice(1, -1)).map(cap)); + } else { + words.push(...splitWords(seg).map(cap)); + } + } + const suffix = words.join(""); + const name = `${verb}${suffix}`; + return name.length > 0 ? name : verb; +} + +/** Route segmentindeki ":id" / "{id}" -> ":id" Nest bicimine normalize eder, + * bas/son "/" temizler. */ +function normalizeRoute(route: string): string { + return route + .split("/") + .filter((s) => s.length > 0) + .map((seg) => + seg.startsWith("{") && seg.endsWith("}") ? `:${seg.slice(1, -1)}` : seg, + ) + .join("/"); +} + +/** Iki route parcasini "/" ile birlestirir (boslari eler). */ +function joinRoutes(a: string, b: string): string { + return [a, b].filter((s) => s.length > 0).join("/"); +} + +/** @Controller route hesabi (DOUBLE-PREFIX FIX). + * Version yoksa -> baseRoute. Version varsa ve baseRoute o version'i bir PATH + * SEGMENTI olarak zaten iceriyorsa (or. base "api/v1/auth", version "v1") -> + * baseRoute oldugu gibi (tekrar onekleme). Aksi halde version onekle. */ +function computeControllerRoute(rawVersion: string | undefined, baseRoute: string): string { + const version = rawVersion ? normalizeRoute(rawVersion) : ""; + if (version.length === 0) return baseRoute; + const baseSegments = baseRoute.split("/").filter((s) => s.length > 0); + const versionSegments = version.split("/").filter((s) => s.length > 0); + // version'in TUM segmentleri baseRoute'ta zaten varsa -> onekleme. + const alreadyHasVersion = versionSegments.every((v) => baseSegments.includes(v)); + if (alreadyHasVersion) return baseRoute; + return joinRoutes(version, baseRoute); +} + +/** Metot dekoratorune giden route argumani. Kok ("/" veya bos) -> argumansiz. */ +function methodRouteArg(route: string): string { + const norm = normalizeRoute(route); + return norm.length > 0 ? JSON.stringify(norm) : ""; +} + +/** Param adini gecerli TS tanimlayicisina cevirir (camelCase, deterministik). */ +function safeIdent(raw: string): string { + const c = camelCase(raw); + if (c.length === 0) return "_param"; + return /^[0-9]/.test(c) ? `_${c}` : c; +} + +/** Path/Query param tipini GECERLI TS'e normalize eder (uuid/int/long/datetime + * vb. -> string/number/Date), model.emitter/dto.emitter ile ayni esleme + * (scalarTsType). Bilinmeyen tip oldugu gibi gecer; bos -> "string". */ +function tsType(raw: string): string { + return scalarTsType(raw); +} + +function cap(w: string): string { + return w.length === 0 ? w : w[0].toUpperCase() + w.slice(1).toLowerCase(); +} + +/* EmitterContext'i import etmeden tip yakalamak icin yerel alias (types.ts'ten). */ +type EmitterContext = Parameters[1]; diff --git a/apps/server/src/codegen/emitters/nestjs/dto.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/dto.emitter.spec.ts new file mode 100644 index 0000000..cffe066 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/dto.emitter.spec.ts @@ -0,0 +1,309 @@ +import { describe, it, expect } from "vitest"; +import { emitDto } from "./dto.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +function storedNode( + type: StoredNode["type"], + properties: Record, + id: string, +): StoredNode { + return { + id, + type, + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function ctxFor(...nodes: StoredNode[]): EmitterContext { + const graph = buildCodeGraph(nodes, []); + return { graph, target: "nestjs" }; +} + +const DTO_ID = "11111111-1111-4111-8111-111111111111"; +const ADDRESS_DTO_ID = "33333333-3333-4333-8333-333333333333"; +const ROLE_ENUM_ID = "44444444-4444-4444-8444-444444444444"; + +/** Ic ice DTO referansi (CreateUserDto.addresses -> AddressDto). */ +const ADDRESS_DTO = storedNode( + "DTO", + { + Name: "AddressDto", + Description: "Adres", + Fields: [ + { Name: "city", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [] }, + ], + }, + ADDRESS_DTO_ID, +); + +/** Enum referansi (CreateUserDto.role -> UserRole). */ +const ROLE_ENUM = storedNode( + "Enum", + { + Name: "UserRole", + Description: "User rolu", + BackingType: "string", + Values: [{ Key: "ADMIN" }, { Key: "MEMBER" }], + }, + ROLE_ENUM_ID, +); + +/** Zengin, gercekci DTO: primitif + dogrulama + opsiyonel + dizi + enum + nested. */ +const CREATE_USER_DTO = storedNode( + "DTO", + { + Name: "CreateUserDto", + Description: "User olusturma payload'i", + Fields: [ + { + Name: "email", + DataType: "string", + IsRequired: true, + IsArray: false, + ValidationRules: [{ Rule: "Email" }, { Rule: "MaxLength", Value: "255" }], + Description: "Benzersiz e-posta", + }, + { + Name: "age", + DataType: "int", + IsRequired: false, + IsArray: false, + ValidationRules: [{ Rule: "Min", Value: "18" }, { Rule: "Max", Value: "120" }], + }, + { + Name: "tags", + DataType: "string", + IsRequired: false, + IsArray: true, + ValidationRules: [{ Rule: "MaxLength", Value: "32" }], + }, + { + Name: "role", + DataType: "string", + IsRequired: true, + IsArray: false, + ValidationRules: [], + EnumRef: "UserRole", + }, + { + Name: "addresses", + DataType: "object", + IsRequired: false, + IsArray: true, + ValidationRules: [], + NestedDTORef: "AddressDto", + }, + ], + }, + DTO_ID, +); + +describe("emitDto", () => { + it("zengin DTO — snapshot", () => { + const ctx = ctxFor(CREATE_USER_DTO, ADDRESS_DTO, ROLE_ENUM); + const [file] = emitDto(ctx.graph.byId(DTO_ID)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { ApiProperty } from "@nestjs/swagger"; + import { Type } from "class-transformer"; + import { IsArray, IsEmail, IsEnum, IsNumber, IsOptional, IsString, Max, MaxLength, Min, ValidateNested } from "class-validator"; + import { UserRole } from "../enums/user-role.enum"; + import { AddressDto } from "./address.dto"; + + /** User olusturma payload'i */ + export class CreateUserDto { + /** Benzersiz e-posta */ + @IsString() + @IsEmail() + @MaxLength(255) + @ApiProperty({ required: true, description: "Benzersiz e-posta" }) + email!: string; + + @IsOptional() + @IsNumber() + @Min(18) + @Max(120) + @ApiProperty({ required: false }) + age?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @MaxLength(32, { each: true }) + @ApiProperty({ required: false, isArray: true }) + tags?: string[]; + + @IsEnum(UserRole) + @ApiProperty({ required: true, enum: UserRole }) + role!: UserRole; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AddressDto) + @ApiProperty({ required: false, type: () => AddressDto, isArray: true }) + addresses?: AddressDto[]; + } + ", + "language": "typescript", + "path": "common/dto/create-user.dto.ts", + "surgicalMarkers": 0, + } + `); + }); + + it("dosya yolu kebab-case /dto altinda", () => { + const ctx = ctxFor(CREATE_USER_DTO, ADDRESS_DTO, ROLE_ENUM); + const [file] = emitDto(ctx.graph.byId(DTO_ID)!, ctx); + // DTO tek basina (tuketen Controller/Service yok) -> "common" feature; dosya + // adi rol son-ekini ("DTO"/"Dto") TEKRARLAMAZ (create-user.dto.ts). + expect(file.path).toBe("common/dto/create-user.dto.ts"); + expect(file.path).toMatch(/\/dto\/.+\.dto\.ts$/); + expect(file.language).toBe("typescript"); + }); + + it("class-validator + class-transformer import'lari sirali ve dogru", () => { + const ctx = ctxFor(CREATE_USER_DTO, ADDRESS_DTO, ROLE_ENUM); + const [file] = emitDto(ctx.graph.byId(DTO_ID)!, ctx); + // Paket import'lari goreli import'lardan once gelir. + expect(file.content).toContain('from "class-validator";'); + expect(file.content).toContain('import { Type } from "class-transformer";'); + const validatorIdx = file.content.indexOf('from "class-validator"'); + const enumImportIdx = file.content.indexOf('from "../enums/user-role.enum"'); + expect(validatorIdx).toBeGreaterThanOrEqual(0); + expect(enumImportIdx).toBeGreaterThan(validatorIdx); + }); + + it("EnumRef -> @IsEnum + goreli import; NestedDTORef -> @ValidateNested + @Type + import", () => { + const ctx = ctxFor(CREATE_USER_DTO, ADDRESS_DTO, ROLE_ENUM); + const [file] = emitDto(ctx.graph.byId(DTO_ID)!, ctx); + expect(file.content).toContain("@IsEnum(UserRole)"); + expect(file.content).toContain("role!: UserRole;"); + expect(file.content).toContain("@ValidateNested({ each: true })"); + expect(file.content).toContain("@Type(() => AddressDto)"); + expect(file.content).toContain("addresses?: AddressDto[];"); + expect(file.content).toContain('import { AddressDto } from "./address.dto";'); + }); + + it("IsRequired=false -> @IsOptional + '?'; IsArray -> @IsArray + '[]' + { each: true }", () => { + const ctx = ctxFor(CREATE_USER_DTO, ADDRESS_DTO, ROLE_ENUM); + const [file] = emitDto(ctx.graph.byId(DTO_ID)!, ctx); + expect(file.content).toContain("@IsOptional()"); + expect(file.content).toContain("age?: number;"); + expect(file.content).toContain("@IsArray()"); + expect(file.content).toContain("tags?: string[];"); + expect(file.content).toContain("@IsString({ each: true })"); + }); + + it("DTO govdesi yok -> surgical marker 0; content ends with single newline", () => { + const ctx = ctxFor(CREATE_USER_DTO, ADDRESS_DTO, ROLE_ENUM); + const [file] = emitDto(ctx.graph.byId(DTO_ID)!, ctx); + expect(file.surgicalMarkers).toBe(0); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: same node twice -> byte-identical", () => { + const ctx = ctxFor(CREATE_USER_DTO, ADDRESS_DTO, ROLE_ENUM); + const a = emitDto(ctx.graph.byId(DTO_ID)!, ctx)[0].content; + const b = emitDto(ctx.graph.byId(DTO_ID)!, ctx)[0].content; + expect(a).toBe(b); + }); + + it("EDGE-CASE: kayip Enum/NestedDTO ref -> throw NONE, import atlanir, TODO birakilir", () => { + // Sadece DTO eklenir; UserRole enum ve AddressDto graph'ta NONE. + const ctx = ctxFor(CREATE_USER_DTO); + const [file] = emitDto(ctx.graph.byId(DTO_ID)!, ctx); + // Tip dekoratoru/tip hâlâ yazilir. + expect(file.content).toContain("@IsEnum(UserRole)"); + expect(file.content).toContain("@Type(() => AddressDto)"); + // Cozulemeyen import'lar eklenmez. + expect(file.content).not.toContain('from "../../common/enums/user-role.enum"'); + expect(file.content).not.toContain('from "./address.dto"'); + // TODO isaretleri birakilir. + expect(file.content).toContain('// TODO(solarch): Enum ref "UserRole" could not be resolved'); + expect(file.content).toContain('// TODO(solarch): NestedDTO ref "AddressDto" could not be resolved'); + }); + + it("EDGE-CASE: bilinmeyen DataType -> tip oldugu gibi, primitif dekorator eklenmez", () => { + const weird = storedNode( + "DTO", + { + Name: "WeirdDto", + Description: "tuhaf", + Fields: [ + { Name: "payload", DataType: "Buffer", IsRequired: true, IsArray: false, ValidationRules: [] }, + ], + }, + "55555555-5555-4555-8555-555555555555", + ); + const ctx = ctxFor(weird); + const [file] = emitDto(ctx.graph.byId(weird.id)!, ctx); + expect(file.content).toContain("payload!: Buffer;"); + expect(file.content).not.toContain("@IsString"); + expect(file.content).not.toContain("@IsNumber"); + }); + + /* ── SELF-REFERENTIAL DTO (tree/ozyinelemeli — CategoryResponse.children) ── + * Audit #5/#28: agac DTO'su temsil edilemiyordu. NestedDTORef DTO'nun KENDISINE + * isaret ederse (children: CategoryResponse[]), tip+@Type uretilir ama sinif KENDI + * dosyasindan import EDILMEZ (zaten kapsamda; self-import = TS hatasi). Bu, agac/ + * ozyinelemeli DTO'lari mumkun kilar (kardinalite ReturnsCollection ile zaten dizi). */ + it("self-referential nested DTO (children) -> Self[] + @Type, kendini import ETMEZ", () => { + const cat = storedNode( + "DTO", + { + Name: "CategoryResponse", + Description: "kategori agaci dugumu", + Fields: [ + { Name: "id", DataType: "string", IsRequired: true, IsArray: false }, + { Name: "children", DataType: "CategoryResponse", IsRequired: false, IsArray: true, NestedDTORef: "CategoryResponse" }, + ], + }, + "ca700000-0000-4000-8000-000000000001", + ); + const ctx = ctxFor(cat); + const [file] = emitDto(ctx.graph.byId(cat.id)!, ctx); + // Ozyinelemeli alan: tip Self[] + @Type(() => Self) + @ValidateNested. + expect(file.content).toMatch(/children\??:\s*CategoryResponse\[\]/); + expect(file.content).toContain("@Type(() => CategoryResponse)"); + expect(file.content).toContain("@ValidateNested"); + // KENDINI import ETMEZ (self-import kirik olurdu). + expect(file.content).not.toMatch(/import \{[^}]*CategoryResponse[^}]*\} from/); + }); + + /* ── Task 7: @ApiProperty decorators (self-documenting generated app) ── + * Each generated DTO field carries an @ApiProperty descriptor so the generated + * app's OpenAPI schema is rich. `required` reflects IsRequired; enum fields + * reference the enum class as a runtime value; nested DTOs use a `type: () => X` + * thunk; arrays set isArray:true; Description carries through. Mirrors the + * controller emitter's @ApiResponse/@ApiOperation key order. */ + it("emits @ApiProperty per field + imports ApiProperty from @nestjs/swagger", () => { + const ctx = ctxFor(CREATE_USER_DTO, ADDRESS_DTO, ROLE_ENUM); + const [file] = emitDto(ctx.graph.byId(DTO_ID)!, ctx); + // one @ApiProperty per field (CreateUserDto has 5 fields) + const count = (file.content.match(/@ApiProperty\(/g) ?? []).length; + expect(count).toBe(5); + // ApiProperty imported from @nestjs/swagger (value import) + expect(file.content).toContain('import { ApiProperty } from "@nestjs/swagger";'); + // required reflects IsRequired; Description carried through + expect(file.content).toContain('@ApiProperty({ required: true, description: "Benzersiz e-posta" })'); + expect(file.content).toContain("@ApiProperty({ required: false })"); + // enum field references the enum class as a runtime value + expect(file.content).toContain("@ApiProperty({ required: true, enum: UserRole })"); + // array primitive sets isArray:true + expect(file.content).toContain("@ApiProperty({ required: false, isArray: true })"); + // nested DTO uses a forward-ref thunk + isArray + expect(file.content).toContain("@ApiProperty({ required: false, type: () => AddressDto, isArray: true })"); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/dto.emitter.ts b/apps/server/src/codegen/emitters/nestjs/dto.emitter.ts new file mode 100644 index 0000000..c33b538 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/dto.emitter.ts @@ -0,0 +1,266 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import { propsOf, type CodeNode } from "../../ir"; +import { camelCase, filePathFor, importPathOf, pascalCase, relativeImportPath } from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers } from "../../surgical"; +import { sqlTypeToTs } from "./sql-type-map"; + +/* ──────────────────────────────────────────────────────────────────────── + * dto.emitter.ts — DTONode -> /dto/.dto.ts (class-validator). + * + * Mirrors enum.emitter.ts (canonical reference) exactly: + * - named `export const emitDto: NodeEmitter`; no default export. + * - PURE function (node, ctx) -> GeneratedFile[]; no I/O, no throw. + * - Path always via filePathFor(node, ctx.graph). + * - imports via ImportCollector (manual "import" FORBIDDEN). + * - DETERMINISTIC: fields in given ORDER, decorators in fixed order. + * - Content ends with single "\n". + * + * DTO BODY is NONE (pure data carrier) -> no surgical markers -> 0. + * + * Each Field -> property + class-validator decorators: + * ValidationRules: Min->@Min, Max->@Max, MinLength->@MinLength, + * MaxLength->@MaxLength, Email->@IsEmail, Url->@IsUrl, + * Regex/Pattern->@Matches, Positive->@IsPositive, + * Negative->@IsNegative. + * DataType: string->@IsString, number/int/float->@IsNumber, + * boolean->@IsBoolean, date->@IsDate. + * IsRequired=false -> @IsOptional + "?" (optional property). + * IsArray=true -> @IsArray + "[]" ({ each: true } on each decorator). + * NestedDTORef -> @ValidateNested + @Type(() => X) (class-transformer) + import. + * EnumRef -> @IsEnum(X) + import. + * ──────────────────────────────────────────────────────────────────────── */ + +export const emitDto: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = propsOf<"DTO">(node); + const className = pascalCase(node.name); + const selfPath = filePathFor(node, ctx.graph); + + const imports = new ImportCollector(); + // class-validator symbols collected in one slot; render() sorts + dedupes. + const validator = (symbol: string) => imports.add(symbol, "class-validator"); + + const lines: string[] = []; + if (props.Description) { + lines.push(`/** ${props.Description} */`); + } + lines.push(`export class ${className} {`); + + const fields = props.Fields ?? []; + fields.forEach((field, idx) => { + if (idx > 0) lines.push(""); + + const isArray = field.IsArray === true; + const optional = field.IsRequired === false; + const eachOpt = isArray ? "{ each: true }" : ""; + + // Type reference for @ApiProperty: enum -> `enum: X`, nested DTO -> `type: () => X`. + // Name written even when unresolved (resolveRef null); consistent with @IsEnum/@Type. + let apiEnumName: string | null = null; + let apiNestedName: string | null = null; + + if (field.Description) lines.push(` /** ${field.Description} */`); + + // ── 1) Optionality ── + if (optional) { + validator("IsOptional"); + lines.push(" @IsOptional()"); + } + + // ── 2) Array ── + if (isArray) { + validator("IsArray"); + lines.push(" @IsArray()"); + } + + // ── 3) Type decorator (EnumRef > NestedDTORef > DataType) ── + let tsType: string; + if (field.EnumRef) { + const enumNode = ctx.graph.resolveRef("Enum", field.EnumRef); + // Class name from RESOLVED node (matches generated enum file export); + // else derived from ref string. + const enumName = pascalCase(enumNode ? enumNode.name : field.EnumRef); + validator("IsEnum"); + lines.push(` @IsEnum(${enumName}${eachOpt ? `, ${eachOpt}` : ""})`); + tsType = enumName; + apiEnumName = enumName; + if (enumNode) { + // feature layout: path via filePathFor(enumNode) (common/enums/... or + // /enums/...); relative path derived from selfPath. + imports.add(enumName, importPathOf(relativeImportPath(selfPath, filePathFor(enumNode, ctx.graph)))); + } else { + // Missing ref -> skip import (resolveRef null); type still written. + lines.push(` // TODO(solarch): Enum ref "${field.EnumRef}" could not be resolved`); + } + } else if (field.NestedDTORef) { + const dtoNode = ctx.graph.resolveRef("DTO", field.NestedDTORef); + const nestedName = pascalCase(dtoNode ? dtoNode.name : field.NestedDTORef); + validator("ValidateNested"); + imports.add("Type", "class-transformer"); + lines.push(` @ValidateNested(${eachOpt})`); + lines.push(` @Type(() => ${nestedName})`); + tsType = nestedName; + apiNestedName = nestedName; + if (dtoNode) { + // SELF-REF (tree/recursive, e.g. children: CategoryResponse[]): class already + // defined in THIS file -> do NOT self-import (self-import TS error). + // Else nested DTO path resolved by feature layout (same feature -> + // "./.dto", else "..//dto/.dto"). + if (dtoNode.id !== node.id) { + imports.add(nestedName, importPathOf(relativeImportPath(selfPath, filePathFor(dtoNode, ctx.graph)))); + } + } else { + lines.push(` // TODO(solarch): NestedDTO ref "${field.NestedDTORef}" could not be resolved`); + } + } else { + const mapped = mapDataType(field.DataType); + tsType = mapped.tsType; + if (mapped.decorator) { + validator(mapped.decorator); + lines.push(` @${mapped.decorator}(${eachOpt})`); + } else { + // Free type (unknown identifier) returned raw -> resolve to generated DTO/Model/Enum + // class: use canonical name (pascalCase) + import. Else raw graph string + // (e.g. "ComplaintResponseDTO") causes TS2304. + const ref = + ctx.graph.resolveRef("DTO", field.DataType) ?? + ctx.graph.resolveRef("Model", field.DataType) ?? + ctx.graph.resolveRef("Enum", field.DataType); + if (ref) { + tsType = pascalCase(ref.name); + imports.add(tsType, importPathOf(relativeImportPath(selfPath, filePathFor(ref, ctx.graph)))); + } + } + } + + // ── 4) ValidationRules (schema order) ── + for (const rule of field.ValidationRules ?? []) { + const dec = mapValidationRule(rule.Rule, rule.Value, eachOpt); + if (!dec) continue; + validator(dec.symbol); + lines.push(` @${dec.symbol}(${dec.args})`); + } + + // ── 5) @ApiProperty (self-documenting generated app) ── + // Each field carries an OpenAPI property descriptor (same key order as + // @ApiResponse/@ApiOperation in controller.emitter: required, description, + // type/enum, isArray). `required` reflects IsRequired; enum field references + // enum class as runtime VALUE; nested DTO uses forward-ref-safe `type: () => X` + // thunk; array field gets isArray:true. + imports.add("ApiProperty", "@nestjs/swagger"); + const apiParts: string[] = [`required: ${!optional}`]; + if (field.Description) apiParts.push(`description: ${JSON.stringify(field.Description)}`); + if (apiEnumName) apiParts.push(`enum: ${apiEnumName}`); + else if (apiNestedName) apiParts.push(`type: () => ${apiNestedName}`); + if (isArray) apiParts.push(`isArray: true`); + lines.push(` @ApiProperty({ ${apiParts.join(", ")} })`); + + // ── 6) Property line ── + // Required (no initializer) fields get definite-assignment "!" so strict:true + // (strictPropertyInitialization) compiles without TS2564 — DTO standard. + // Optional "?" fields untouched. + const opt = optional ? "?" : ""; + const assertion = optional ? "" : "!"; + const arr = isArray ? "[]" : ""; + lines.push(` ${camelCase(field.Name)}${opt}${assertion}: ${tsType}${arr};`); + }); + + lines.push("}"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: selfPath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/* ── DataType -> (TS type, class-validator decorator) ───────────────────── + * DataType is a free string; common synonyms normalized (case-insensitive). + * Unknown type -> tsType as pascal'd reference, no primitive decorator (avoid + * wrong validation). */ +function mapDataType(dataType: string): { tsType: string; decorator: string | null } { + // TS type from sql-type-map SINGLE SOURCE (consistent with entity/Model); DTO + // is free type so unknown passes AS-IS (unknownAsString=false). + // Decorator (class-validator) mapping is DTO-specific. + const tsType = sqlTypeToTs(dataType, false); + switch (tsType) { + case "string": + return { tsType, decorator: "IsString" }; + case "number": + return { tsType, decorator: "IsNumber" }; + case "boolean": + return { tsType, decorator: "IsBoolean" }; + case "Date": + return { tsType, decorator: "IsDate" }; + case "Record": + // JSON/JSONB free object -> no validatable primitive decorator. + return { tsType, decorator: null }; + default: + // Unknown free type: use as-is; do not add wrong validation. + return { tsType: tsType.length > 0 ? tsType : "unknown", decorator: null }; + } +} + +/* ── ValidationRule -> class-validator decorator ────────────────────────── + * When eachOpt given (array field) append { each: true } to numeric/length + * decorators. Rule skipped silently when value unparseable (no throw). */ +function mapValidationRule( + rule: string, + value: string | undefined, + eachOpt: string, +): { symbol: string; args: string } | null { + const withEach = (primary: string) => (eachOpt ? `${primary}, ${eachOpt}` : primary); + const num = (): number | null => { + if (value === undefined || value === "") return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; + }; + + switch (rule) { + case "Min": { + const n = num(); + return n === null ? null : { symbol: "Min", args: withEach(String(n)) }; + } + case "Max": { + const n = num(); + return n === null ? null : { symbol: "Max", args: withEach(String(n)) }; + } + case "MinLength": { + const n = num(); + return n === null ? null : { symbol: "MinLength", args: withEach(String(n)) }; + } + case "MaxLength": { + const n = num(); + return n === null ? null : { symbol: "MaxLength", args: withEach(String(n)) }; + } + case "Email": + return { symbol: "IsEmail", args: eachOpt ? `undefined, ${eachOpt}` : "" }; + case "Url": + return { symbol: "IsUrl", args: eachOpt ? `undefined, ${eachOpt}` : "" }; + case "Regex": + case "Pattern": { + if (value === undefined || value === "") return null; + return { symbol: "Matches", args: withEach(toRegexLiteral(value)) }; + } + case "Positive": + return { symbol: "IsPositive", args: eachOpt }; + case "Negative": + return { symbol: "IsNegative", args: eachOpt }; + default: + return null; + } +} + +/** Convert a pattern string to a safe RegExp literal. If already "/.../" + * form keep it; else escape "/" and wrap. */ +function toRegexLiteral(pattern: string): string { + if (pattern.length >= 2 && pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) { + return pattern; + } + return `/${pattern.replace(/\//g, "\\/")}/`; +} diff --git a/apps/server/src/codegen/emitters/nestjs/entity-synthesis.spec.ts b/apps/server/src/codegen/emitters/nestjs/entity-synthesis.spec.ts new file mode 100644 index 0000000..a578dde --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/entity-synthesis.spec.ts @@ -0,0 +1,429 @@ +import { describe, it, expect } from "vitest"; +import { + emitSyntheticEntity, + entityClassNameForTable, + synthEntityFilePath, + tablesNeedingSyntheticEntity, +} from "./entity-synthesis"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; +import type { NodeKind } from "../../../nodes/schemas"; +import type { EdgeKind } from "../../../edges/schemas/edge.schema"; + +/* ──────────────────────────────────────────────────────────────────────── + * entity-synthesis.spec.ts — Table'dan SENTEZLENEN TypeORM entity. + * + * Model'i WITHOUT ama bir Repository tarafindan referans edilen Table icin + * @Entity sinifi uretilir; @InjectRepository/Repository/forFeature tutarli + * olur ve uygulama BOOT BOOTS (Table-only graph BOOT garantisi). + * ──────────────────────────────────────────────────────────────────────── */ + +let seq = 0; +function node(type: NodeKind, properties: Record): StoredNode { + seq += 1; + return { + id: `00000000-0000-4000-8000-${String(seq).padStart(12, "0")}`, + type, + projectId: "11111111-1111-4111-8111-111111111111", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} +function edge(kind: EdgeKind, s: StoredNode, t: StoredNode): StoredEdge { + seq += 1; + return { + id: `e0000000-0000-4000-8000-${String(seq).padStart(12, "0")}`, + projectId: "11111111-1111-4111-8111-111111111111", + sourceNodeId: s.id, + targetNodeId: t.id, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: false }, + }; +} +function ctxFor(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { + return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; +} + +const imagesTable = () => + node("Table", { + TableName: "GeneratedImages", + Description: "Uretilen gorseller", + Columns: [ + { Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }, + { Name: "url", DataType: "TEXT", IsPrimaryKey: false, IsNotNull: false, IsUnique: false, AutoIncrement: false }, + ], + }); + +const imageRepo = () => + node("Repository", { + RepositoryName: "ImageRepository", + Description: "Gorsel erisimi", + EntityReference: "GeneratedImages", + IsCached: false, + CustomQueries: [], + }); + +describe("entity-synthesis", () => { + it("entityClassNameForTable: tablo adi tekil-pascal'a cevrilir", () => { + const t = imagesTable(); + expect(entityClassNameForTable(buildCodeGraph([t], []).byId(t.id)!)).toBe("GeneratedImage"); + const users = node("Table", { TableName: "Users", Description: "x", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }] }); + expect(entityClassNameForTable(buildCodeGraph([users], []).byId(users.id)!)).toBe("User"); + }); + + it("synthEntityFilePath: /entities/.entity.ts", () => { + const t = imagesTable(); + const repo = imageRepo(); + const graph = buildCodeGraph([t, repo], [edge("WRITES", repo, t)]); + expect(synthEntityFilePath(graph.byId(t.id)!, graph)).toBe("image/entities/generated-image.entity.ts"); + }); + + it("tablesNeedingSyntheticEntity: yalniz repo-referansli + Model'siz Table'lar", () => { + const t = imagesTable(); + const repo = imageRepo(); + const graph = buildCodeGraph([t, repo], [edge("WRITES", repo, t)]); + expect(tablesNeedingSyntheticEntity(graph).map((x) => x.name)).toEqual(["GeneratedImages"]); + }); + + it("Model'i OLAN Table icin sentez YAPILMAZ (Model entity uretilir)", () => { + const t = imagesTable(); + const model = node("Model", { ClassName: "GeneratedImage", Description: "x", TableRef: "GeneratedImages", Properties: [{ Name: "id", Type: "uuid" }], Methods: [] }); + const repo = imageRepo(); + const graph = buildCodeGraph([t, model, repo], [edge("WRITES", repo, t)]); + expect(tablesNeedingSyntheticEntity(graph)).toEqual([]); + }); + + it("hicbir repository referans etmeyen Table icin sentez YAPILMAZ", () => { + const t = imagesTable(); + const graph = buildCodeGraph([t], []); + expect(tablesNeedingSyntheticEntity(graph)).toEqual([]); + }); + + it("emitSyntheticEntity: @Entity(fiziksel ad) + PK @PrimaryGeneratedColumn + kolonlar", () => { + const t = imagesTable(); + const repo = imageRepo(); + const ctx = ctxFor([t, repo], [edge("WRITES", repo, t)]); + const [file] = emitSyntheticEntity(ctx.graph.byId(t.id)!, ctx); + expect(file.path).toBe("image/entities/generated-image.entity.ts"); + // @Entity adi migration tablo adiyla AYNI (tableSqlName). + expect(file.content).toContain('@Entity("generated_images")'); + expect(file.content).toContain("export class GeneratedImage {"); + expect(file.content).toContain("@PrimaryGeneratedColumn(\"uuid\")"); + expect(file.content).toContain("id!: string;"); + expect(file.content).toContain("@Column("); + expect(file.content).toContain("url?: string;"); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.surgicalMarkers).toBe(0); + }); + + it("DETERMINISM: ayni table iki kez -> byte-identical", () => { + const t = imagesTable(); + const repo = imageRepo(); + const ctx = ctxFor([t, repo], [edge("WRITES", repo, t)]); + const a = emitSyntheticEntity(ctx.graph.byId(t.id)!, ctx)[0].content; + const b = emitSyntheticEntity(ctx.graph.byId(t.id)!, ctx)[0].content; + expect(a).toBe(b); + }); + + it("FK olmayan table -> iliski dekoratoru URETILMEZ (mevcut akis korunur)", () => { + const t = imagesTable(); + const repo = imageRepo(); + const ctx = ctxFor([t, repo], [edge("WRITES", repo, t)]); + const [file] = emitSyntheticEntity(ctx.graph.byId(t.id)!, ctx); + expect(file.content).not.toContain("@ManyToOne"); + expect(file.content).not.toContain("@OneToMany"); + expect(file.content).not.toContain("@JoinColumn"); + }); + + describe("ILISKI SENTEZI (M2)", () => { + // users <- posts.author_id (FK). Ikisi de Model'siz + repo-referansli -> + // sentetik entity. posts -> @ManyToOne(User), users -> @OneToMany(Post). + const usersTable = () => + node("Table", { + TableName: "users", + Description: "Userlar", + Columns: [ + { Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }, + ], + }); + const postsTable = (nullableFk = false) => + node("Table", { + TableName: "posts", + Description: "Gonderiler", + Columns: [ + { Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }, + { Name: "author_id", DataType: "UUID", IsPrimaryKey: false, IsNotNull: !nullableFk, IsUnique: false, AutoIncrement: false }, + ], + ForeignKeys: [ + { Columns: ["author_id"], ReferencesTable: "users", ReferencesColumns: ["id"], OnDelete: "CASCADE", OnUpdate: "NO_ACTION" }, + ], + }); + const repoFor = (name: string, entity: string) => + node("Repository", { RepositoryName: name, Description: "x", EntityReference: entity, IsCached: false, CustomQueries: [] }); + + function relCtx(nullableFk = false): EmitterContext { + const users = usersTable(); + const posts = postsTable(nullableFk); + const usersRepo = repoFor("UsersRepository", "users"); + const postsRepo = repoFor("PostsRepository", "posts"); + return ctxFor([users, posts, usersRepo, postsRepo], []); + } + + it("FK sahip tarafi -> @ManyToOne + @JoinColumn(fiziksel FK kolonu), eager:false", () => { + const ctx = relCtx(); + const posts = ctx.graph.allOf("Table").find((t) => t.name === "posts")!; + const [file] = emitSyntheticEntity(posts, ctx); + expect(file.content).toContain("@ManyToOne(() => User, { eager: false })"); + expect(file.content).toContain('@JoinColumn({ name: "author_id" })'); + expect(file.content).toContain("author!: User;"); + // Sentetik entity import'u + typeorm dekorator import'lari. + expect(file.content).toContain("import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from \"typeorm\";"); + expect(file.content).toContain("entities/user.entity"); + expect(file.surgicalMarkers).toBe(0); + }); + + it("FK ters tarafi -> @OneToMany(() => Post, (post) => post.author), definite-assignment", () => { + const ctx = relCtx(); + const users = ctx.graph.allOf("Table").find((t) => t.name === "users")!; + const [file] = emitSyntheticEntity(users, ctx); + // author_id FK ON DELETE CASCADE -> aggregate -> @OneToMany cascade: true. + expect(file.content).toContain("@OneToMany(() => Post, (post) => post.author, { cascade: true })"); + // TypeORM iliski property'lerinde dizi initializer'i (= []) YASAKLAR + // (InitializedRelationError -> migration/boot patlar). "!" kullanilir. + expect(file.content).toContain("posts!: Post[];"); + expect(file.content).not.toContain("posts: Post[] = [];"); + expect(file.content).toContain("import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from \"typeorm\";"); + expect(file.content).toContain("entities/post.entity"); + }); + + it("nullable FK -> opsiyonel @ManyToOne (nullable: true, '?' alan)", () => { + const ctx = relCtx(true); + const posts = ctx.graph.allOf("Table").find((t) => t.name === "posts")!; + const [file] = emitSyntheticEntity(posts, ctx); + expect(file.content).toContain("@ManyToOne(() => User, { eager: false, nullable: true })"); + expect(file.content).toContain("author?: User;"); + }); + + it("FK KAPANISI: repo-referanssiz ama core tablonun FK hedefi -> entity sentezlenir + iliski URETILIR", () => { + // users hicbir repository tarafindan referans EDILMIYOR; ama core (repo-referansli) + // posts ondan FK ile bagli -> FK kapanisi users'i da sentetik entity'ye ceker + // (sema<->ORM kapsami tam: FK iliskisi @ManyToOne(User) cozulebilir). + const users = usersTable(); + const posts = postsTable(); + const postsRepo = repoFor("PostsRepository", "posts"); + const ctx = ctxFor([users, posts, postsRepo], []); + // users artik sentez kumesinde. + expect(tablesNeedingSyntheticEntity(ctx.graph).map((t) => t.name).sort()).toEqual(["posts", "users"]); + const postsNode = ctx.graph.allOf("Table").find((t) => t.name === "posts")!; + const [file] = emitSyntheticEntity(postsNode, ctx); + // Karsi taraf (users) artik sentetik entity -> @ManyToOne(User) uretilir. + expect(file.content).toContain("@ManyToOne(() => User, { eager: false })"); + expect(file.content).toContain('@JoinColumn({ name: "author_id" })'); + }); + + it("SAFLIK: hedef tablonun Model'i VAR -> iliski URETILMEZ (Model entity ayri)", () => { + const users = usersTable(); + const posts = postsTable(); + const userModel = node("Model", { ClassName: "User", Description: "x", TableRef: "users", Properties: [{ Name: "id", Type: "uuid" }], Methods: [] }); + const usersRepo = repoFor("UsersRepository", "users"); + const postsRepo = repoFor("PostsRepository", "posts"); + const ctx = ctxFor([users, posts, userModel, usersRepo, postsRepo], []); + const postsNode = ctx.graph.allOf("Table").find((t) => t.name === "posts")!; + const [file] = emitSyntheticEntity(postsNode, ctx); + expect(file.content).not.toContain("@ManyToOne"); + }); + + it("composite (cok-kolon) FK -> iliski URETILMEZ (tek-kolon esleme yapilamaz)", () => { + const parent = node("Table", { + TableName: "parents", + Description: "x", + Columns: [ + { Name: "a", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + { Name: "b", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + ], + }); + const child = node("Table", { + TableName: "children", + Description: "x", + Columns: [ + { Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }, + { Name: "pa", DataType: "UUID", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + { Name: "pb", DataType: "UUID", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + ], + ForeignKeys: [ + { Columns: ["pa", "pb"], ReferencesTable: "parents", ReferencesColumns: ["a", "b"], OnDelete: "NO_ACTION", OnUpdate: "NO_ACTION" }, + ], + }); + const parentRepo = repoFor("ParentRepository", "parents"); + const childRepo = repoFor("ChildRepository", "children"); + const ctx = ctxFor([parent, child, parentRepo, childRepo], []); + const childNode = ctx.graph.allOf("Table").find((t) => t.name === "children")!; + const [file] = emitSyntheticEntity(childNode, ctx); + expect(file.content).not.toContain("@ManyToOne"); + }); + + it("DETERMINISM: iliskili graph -> byte-identical iki kez", () => { + const ctx = relCtx(); + const posts = ctx.graph.allOf("Table").find((t) => t.name === "posts")!; + const a = emitSyntheticEntity(posts, ctx)[0].content; + const b = emitSyntheticEntity(posts, ctx)[0].content; + expect(a).toBe(b); + }); + }); + + describe("TIP MAP (#1): ENUM/JSON + skaler tipler STRICT TS uretir", () => { + const enumNode = () => + node("Enum", { + Name: "OrderStatus", + Description: "Order lifecycle", + BackingType: "string", + Values: [{ Key: "PENDING" }, { Key: "PAID" }], + }); + // VARCHAR/UUID/INT/BIGINT/DECIMAL/FLOAT/BOOLEAN/DATETIME/DATE/JSON/ENUM hepsi. + const richTable = () => + node("Table", { + TableName: "orders", + Description: "Orders", + Columns: [ + { Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }, + { Name: "code", DataType: "VARCHAR", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + { Name: "qty", DataType: "INT", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + { Name: "big", DataType: "BIGINT", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + { Name: "total", DataType: "DECIMAL", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + { Name: "rate", DataType: "FLOAT", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + { Name: "is_paid", DataType: "BOOLEAN", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + { Name: "created_at", DataType: "DATETIME", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + { Name: "due_on", DataType: "DATE", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + { Name: "metadata", DataType: "JSON", IsPrimaryKey: false, IsNotNull: false, IsUnique: false, AutoIncrement: false }, + { Name: "status", DataType: "ENUM", EnumRef: "OrderStatus", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + ], + }); + const repo = () => node("Repository", { RepositoryName: "OrderRepository", Description: "x", EntityReference: "orders", IsCached: false, CustomQueries: [] }); + + it("ENUM kolon -> TS tipi generated enum + @Column({ type:'varchar' }) (#56: native enum NOT, migration CHECK ile)", () => { + const ctx = ctxFor([richTable(), repo(), enumNode()], []); + const orders = ctx.graph.allOf("Table").find((t) => t.name === "orders")!; + const [file] = emitSyntheticEntity(orders, ctx); + // ESKI HATA: `status!: ENUM;` (gecersiz TS). + expect(file.content).not.toContain("ENUM;"); + // TS tipi yine generated enum sinifi; ama @Column VARCHAR -> entity↔migration + // tutarli (migration de VARCHAR + CHECK). native Postgres enum URETILMEZ. + expect(file.content).toMatch(/@Column\(\{ type: "varchar" \}\)\s*\n\s*status!: OrderStatus;/); + expect(file.content).toContain('import { OrderStatus } from "../../common/enums/order-status.enum";'); + expect(file.content).not.toContain('type: "enum"'); + expect(file.content).not.toContain("enum: OrderStatus"); + }); + + it("EnumRef cozulemez -> guvenli string + @Column({ type:'varchar' }) (throw NONE)", () => { + const ctx = ctxFor([richTable(), repo()], []); // Enum node NONE + const orders = ctx.graph.allOf("Table").find((t) => t.name === "orders")!; + const [file] = emitSyntheticEntity(orders, ctx); + expect(file.content).toContain("status!: string;"); + expect(file.content).toContain('@Column({ type: "varchar" })'); + expect(file.content).not.toContain("OrderStatus"); + }); + + it("JSON kolon -> Record + @Column({ type:'jsonb' }) (ESKI HATA: `JSON;`)", () => { + const ctx = ctxFor([richTable(), repo(), enumNode()], []); + const orders = ctx.graph.allOf("Table").find((t) => t.name === "orders")!; + const [file] = emitSyntheticEntity(orders, ctx); + expect(file.content).not.toMatch(/:\s*JSON;/); + expect(file.content).toContain("metadata?: Record;"); + expect(file.content).toContain('@Column({ type: "jsonb", nullable: true })'); + }); + + it("skaler tipler dogru TS + TypeORM tipine eslenir", () => { + const ctx = ctxFor([richTable(), repo(), enumNode()], []); + const orders = ctx.graph.allOf("Table").find((t) => t.name === "orders")!; + const [file] = emitSyntheticEntity(orders, ctx); + expect(file.content).toContain("id!: string;"); // UUID -> string + expect(file.content).toContain('@Column({ type: "varchar" })'); + expect(file.content).toContain("code!: string;"); + expect(file.content).toContain('@Column({ type: "int" })'); + expect(file.content).toContain("qty!: number;"); + expect(file.content).toContain('@Column({ type: "bigint" })'); + expect(file.content).toContain("big!: number;"); + expect(file.content).toContain('@Column({ type: "decimal" })'); + expect(file.content).toContain("total!: number;"); + expect(file.content).toContain('@Column({ type: "double precision" })'); + expect(file.content).toContain("rate!: number;"); + expect(file.content).toContain('@Column({ type: "boolean" })'); + // TS uye adi idiomatik camelCase (is_paid→isPaid); DB kolonu snake_case kalir (SnakeNamingStrategy). + expect(file.content).toContain("isPaid!: boolean;"); + expect(file.content).toContain('@Column({ type: "timestamp" })'); + expect(file.content).toContain("createdAt!: Date;"); + expect(file.content).toContain('@Column({ type: "date" })'); + expect(file.content).toContain("dueOn!: Date;"); + }); + }); + + describe("SEMA<->ORM KAPSAMI (#9): join/ara tablolar da sentezlenir", () => { + // orders <- order_items -> products. order_items NE bir repo gosterir NE de + // Model'i var; ama core tablolara (orders/products) FK verir -> FK kapanisi + // onu da sentetik entity'ye ceker (migration var, entity de olur). + const tbl = (name: string, cols: Record[], fks: Record[] = []) => + node("Table", { TableName: name, Description: name, Columns: cols, ForeignKeys: fks }); + const pkCol = { Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }; + const fkCol = (n: string) => ({ Name: n, DataType: "UUID", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }); + const repo = (name: string, entity: string) => node("Repository", { RepositoryName: name, Description: "x", EntityReference: entity, IsCached: false, CustomQueries: [] }); + + function joinCtx(): EmitterContext { + const orders = tbl("orders", [pkCol]); + const products = tbl("products", [pkCol]); + const orderItems = tbl( + "order_items", + [pkCol, fkCol("order_id"), fkCol("product_id")], + [ + { Columns: ["order_id"], ReferencesTable: "orders", ReferencesColumns: ["id"], OnDelete: "CASCADE", OnUpdate: "NO_ACTION" }, + { Columns: ["product_id"], ReferencesTable: "products", ReferencesColumns: ["id"], OnDelete: "RESTRICT", OnUpdate: "NO_ACTION" }, + ], + ); + // Yalniz orders + products bir repo gosterir; order_items GOSTERMEZ. + return ctxFor([orders, products, orderItems, repo("OrderRepository", "orders"), repo("ProductRepository", "products")], []); + } + + it("order_items (join, repo-referanssiz) entity SENTEZLENIR", () => { + const names = tablesNeedingSyntheticEntity(joinCtx().graph).map((t) => t.name).sort(); + expect(names).toEqual(["order_items", "orders", "products"]); + }); + + it("order_items entity -> her FK icin @ManyToOne(core entity) + @JoinColumn", () => { + const ctx = joinCtx(); + const oi = ctx.graph.allOf("Table").find((t) => t.name === "order_items")!; + const [file] = emitSyntheticEntity(oi, ctx); + expect(file.content).toContain("export class OrderItem {"); + expect(file.content).toContain("@ManyToOne(() => Order, { eager: false })"); + expect(file.content).toContain('@JoinColumn({ name: "order_id" })'); + expect(file.content).toContain("@ManyToOne(() => Product, { eager: false })"); + expect(file.content).toContain('@JoinColumn({ name: "product_id" })'); + }); + + it("orders entity -> @OneToMany(OrderItem) + cascade (order_items->orders FK CASCADE)", () => { + const ctx = joinCtx(); + const orders = ctx.graph.allOf("Table").find((t) => t.name === "orders")!; + const [file] = emitSyntheticEntity(orders, ctx); + // order_items->orders FK ON DELETE CASCADE -> aggregate -> cascade: true. + expect(file.content).toContain("@OneToMany(() => OrderItem, (orderItem) => orderItem.order, { cascade: true })"); + expect(file.content).toContain("orderItems!: OrderItem[];"); + expect(file.content).not.toContain("orderItems: OrderItem[] = [];"); + }); + + it("products entity -> @OneToMany(OrderItem) cascade NONE (order_items->products FK RESTRICT)", () => { + const ctx = joinCtx(); + const products = ctx.graph.allOf("Table").find((t) => t.name === "products")!; + const [file] = emitSyntheticEntity(products, ctx); + // order_items->products FK RESTRICT -> bagimsiz iliski -> cascade NONE. + expect(file.content).toContain("@OneToMany(() => OrderItem, (orderItem) => orderItem.product)"); + expect(file.content).not.toContain("orderItem.product, { cascade"); + }); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/entity-synthesis.ts b/apps/server/src/codegen/emitters/nestjs/entity-synthesis.ts new file mode 100644 index 0000000..f33a115 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/entity-synthesis.ts @@ -0,0 +1,479 @@ +import type { EmitterContext, GeneratedFile } from "../../types"; +import { propsOf, type CodeGraph, type CodeNode } from "../../ir"; +import { + camelCase, + entityClassNameForTable, + filePathFor, + importPathOf, + pascalCase, + pluralizeSnake, + relativeImportPath, + singularize, + snakeCase, + synthEntityFilePath, + tableSqlName, + tsPropName, +} from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers } from "../../surgical"; +import { columnOrmType, columnTsType } from "./sql-type-map"; + +// Entity name/path SINGLE SOURCE is in naming.ts (resolveTypeRef depends on it); +// re-exported here for backward compatibility. +export { entityClassNameForTable, synthEntityFilePath }; + +/* ──────────────────────────────────────────────────────────────────────── + * entity-synthesis.ts — SYNTHESIZED TypeORM entity from Table. + * + * ARCHITECTURE-AWARE BOOT GUARANTEE: In real graphs often no Model node, + * only Table nodes (Users/GeneratedImages -> migrations/*.sql). When a + * Repository.EntityReference points to a Table, repository.emitter emits + * `@InjectRepository(Entity)`/`Repository` and module.emitter adds + * `TypeOrmModule.forFeature([Entity])`. If no TypeORM @Entity class exists + * NestJS DI cannot resolve `Repository` at boot and the app WON'T START. + * This module emits a deterministic @Entity class from Table schema for every + * Table referenced by a Repository but WITHOUT a Model. + * + * SINGLE SOURCE rules: + * - Class name: entityClassNameForTable(table) — avoid Model<->Table collision + * via singular-pascal of table name (UsersTable.name="users" -> "User"; + * "generated_images" -> "GeneratedImage"). repository.emitter uses same name. + * - @Entity(): tableSqlName(table.name) — EXACTLY same as table.emitter's + * `CREATE TABLE` name (entity never binds to nonexistent table). + * - File path: synthEntityFilePath(table, graph) — /entities/.entity.ts + * (same layout as Model entity path; if Model exists in same feature, Model + * file is emitted — orchestrator synthesizes only for Tables WITHOUT Model). + * + * RELATION SYNTHESIS (M2): Table has no @OneToMany/@ManyToOne; only SQL FKs. + * Without relation decorators on entity, N+1/lazy decision stays entirely with + * surgical. This module DETERMINISTICALLY synthesizes TypeORM relations from + * Table ForeignKeys: + * - FK (this table -> target) -> owning side @ManyToOne(() => Target) + @JoinColumn. + * - Inverse (other table -> this table) -> @OneToMany(() => Other, x => x.). + * Default eager:false / lazy:false (no auto-load -> no N+1 explosion). + * PURITY RULE: a relation is emitted ONLY when the other side also resolves to a + * SYNTHETIC entity (Table without Model + repository-referenced). If other side + * cannot resolve (has Model, not repo-referenced, or missing) relation is NOT + * emitted — to avoid importing nonexistent class and breaking compile. + * Bidirectional @OneToMany uses owning-side property name (deterministically + * derived from FK column) in inverse function. + * + * PURE + DETERMINISTIC: columns in given order, relations in FK order (ManyToOne) + * then target-table name order (OneToMany), imports via ImportCollector, + * no timestamp/random, content ends with single "\n". Relation body is NOT emitted + * -> no surgical marker. + * ──────────────────────────────────────────────────────────────────────── */ + +type Column = { + Name: string; + DataType: string; + Length?: number; + IsPrimaryKey: boolean; + IsNotNull: boolean; + IsUnique: boolean; + AutoIncrement: boolean; + EnumRef?: string; +}; + +type ForeignKey = { + Name?: string; + Columns: string[]; + ReferencesTable: string; + ReferencesColumns: string[]; + OnDelete?: string; + OnUpdate?: string; +}; + +/** Tables that will have entity SYNTHESIZED (without Model). SINGLE SOURCE — module.emitter + * forFeature, repository.emitter, naming.resolveTypeRef and relation synthesis + * (isSyntheticEntityTable) all stay consistent with this set. + * + * SET (deterministic, FK closure): + * 1) CORE: Tables pointed to by a Repository.EntityReference. + * 2) CLOSURE: every Model-less Table linked via FK to a core Table (FK to it + * OR FK from it) — e.g. join/bridge tables + * (order_items: neither pointed by a repo nor has Model; but FKs to orders and + * products). Without @Entity for these, FK relations + * (orders -> @OneToMany(OrderItem)) cannot resolve and schema<->ORM scope is + * incomplete (migration exists, entity missing). Transitive closure via bidirectional FK. + * + * Tables WITH Model are NEVER included (Model entity is emitted). + * graph.allOf sorted by name + fixed fixpoint -> deterministic. */ +export function tablesNeedingSyntheticEntity(graph: CodeGraph): CodeNode[] { + const ids = computeSyntheticEntityIds(graph); + const out: CodeNode[] = []; + for (const table of graph.allOf("Table")) { + if (ids.has(table.id)) out.push(table); + } + return out; +} + +/** Table ids that get synthetic entity (FK closure; deterministic). */ +function computeSyntheticEntityIds(graph: CodeGraph): Set { + // Model-less Tables are candidates; those with Model excluded (Model entity emitted). + const tables = graph.allOf("Table").filter((t) => !hasBackingModel(t, graph)); + const byTableName = new Map(); + for (const t of tables) byTableName.set(t.name, t); + + // ── 1) CORE: repo-referenced Model-less Tables ────────────────────── + const set = new Set(); + for (const repo of graph.allOf("Repository")) { + const ref = (repo.properties as Record).EntityReference; + if (typeof ref !== "string" || ref.length === 0) continue; + const node = graph.resolveRef(["Model", "Table"], ref); + if (node && node.kindOf() === "Table" && !hasBackingModel(node, graph)) { + set.add(node.id); + } + } + + // ── 2) FK CLOSURE (transitive, bidirectional; among Model-less candidates) ──── + let changed = true; + while (changed) { + changed = false; + for (const t of tables) { + // (a) t -> target FKs: if t in set add target; if target in set add t. + const fks = (propsOf<"Table">(t).ForeignKeys ?? []) as ForeignKey[]; + for (const fk of fks) { + const target = byTableName.get(fk.ReferencesTable); + if (!target) continue; + const tIn = set.has(t.id); + const targetIn = set.has(target.id); + if (tIn && !targetIn) { + set.add(target.id); + changed = true; + } else if (targetIn && !tIn) { + set.add(t.id); + changed = true; + } + } + } + } + + return set; +} + +/** Does a Model represent this Table via TableRef? (if yes Model entity + * is emitted; synthesis unnecessary.) */ +function hasBackingModel(table: CodeNode, graph: CodeGraph): boolean { + for (const m of graph.allOf("Model")) { + const tableRef = (m.properties as Record).TableRef; + if (typeof tableRef === "string" && graph.resolveRef("Table", tableRef)?.id === table.id) { + return true; + } + } + return false; +} + +/** Convert a Table node to SYNTHESIZED TypeORM entity file. */ +export function emitSyntheticEntity(table: CodeNode, ctx: EmitterContext): GeneratedFile[] { + const props = propsOf<"Table">(table); + const className = entityClassNameForTable(table); + const tableName = tableSqlName(table.name); + const columns = (props.Columns ?? []) as Column[]; + + const imports = new ImportCollector(); + imports.add("Column", "typeorm"); + imports.add("Entity", "typeorm"); + + const pk = pickPrimaryKey(columns); + + // Reserve column names to avoid member name collisions; relation + // properties added to this set (deterministic; conflicting relation SKIPPED). + const usedNames = new Set(columns.map((c) => c.Name)); + + const fromPath = synthEntityFilePath(table, ctx.graph); + const memberBlocks: string[] = []; + for (const col of columns) { + memberBlocks.push(renderColumn(col, col === pk, imports, ctx.graph, fromPath)); + } + + // RELATION SYNTHESIS (M2): TypeORM relation decorators from FKs. If other side + // does not resolve to synthetic entity, relation not emitted (pure/deterministic). + for (const block of synthesizeRelations(table, ctx.graph, imports, usedNames)) { + memberBlocks.push(block); + } + + const lines: string[] = []; + if (props.Description) lines.push(`/** ${props.Description} (synthesized from Table) */`); + else lines.push(`/** ${className} entity (synthesized from Table "${tableName}"). */`); + lines.push(`@Entity(${JSON.stringify(tableName)})`); + lines.push(`export class ${className} {`); + lines.push(memberBlocks.join("\n\n")); + lines.push("}"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: synthEntityFilePath(table, ctx.graph), + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +} + +/* ──────────────────────────────────────────────────────────────────────── + * RELATION SYNTHESIS (M2) — TypeORM @ManyToOne/@OneToMany from FKs. + * + * Owning side (@ManyToOne): for each SINGLE-COLUMN FK of this table when target + * resolves to a SYNTHETIC entity. @JoinColumn binds FK column. + * Inverse (@OneToMany): for each OTHER synthetic entity with FK pointing to this table. + * Bidirectional: inverse function uses owning-side property name + * (same FK + same algorithm -> both sides derive SAME name). + * Default { eager: false } (lazy:false; no auto-load -> no N+1). + * PURITY: if other side is not synthetic entity (has Model / not repo-referenced / missing) + * relation SKIPPED. Multi-column (composite) FK SKIPPED (no single-column mapping). + * ──────────────────────────────────────────────────────────────────────── */ +function synthesizeRelations( + table: CodeNode, + graph: CodeGraph, + imports: ImportCollector, + usedNames: Set, +): string[] { + const fromPath = synthEntityFilePath(table, graph); + const blocks: string[] = []; + + // ── Owning side: this table's FKs -> @ManyToOne ──────────────────────── + // owningRelationProps SINGLE SOURCE: same property names here (owning side) and + // inverse direction -> consistent bidirectional. + const ownCols = (propsOf<"Table">(table).Columns ?? []) as Column[]; + const fks = (propsOf<"Table">(table).ForeignKeys ?? []) as ForeignKey[]; + const ownProps = owningRelationProps(table, graph); + for (let i = 0; i < fks.length; i++) { + const prop = ownProps.get(i); + if (!prop) continue; // FK not emitted (composite / not synthetic / collision) + usedNames.add(prop); + const fkCol = ownCols.find((c) => c.Name === (fks[i].Columns ?? [])[0]); + // If FK column not NOT NULL, relation is optional (read from column schema). + const nullable = fkCol ? fkCol.IsNotNull !== true : false; + const ref = resolveSyntheticEntity(fks[i].ReferencesTable, graph)!; + blocks.push(renderManyToOne(prop, ref, (fks[i].Columns ?? [])[0], nullable, graph, imports, fromPath)); + } + + // ── Inverse: OTHER synthetic entity FKs pointing to this table -> @OneToMany + for (const other of graph.allOf("Table")) { + if (other.id === table.id) continue; + if (!isSyntheticEntityTable(other, graph)) continue; + // Replay owning-side property names from other table with SAME loop + // -> inverse (x) => x. stays consistent on both sides. + const owningProps = owningRelationProps(other, graph); + const otherFks = (propsOf<"Table">(other).ForeignKeys ?? []) as ForeignKey[]; + for (let i = 0; i < otherFks.length; i++) { + const fk = otherFks[i]; + const cols = fk.Columns ?? []; + if (cols.length !== 1) continue; + const target = resolveSyntheticEntity(fk.ReferencesTable, graph); + if (!target || target.id !== table.id) continue; // does not point to me + const owningProp = owningProps.get(i); + if (!owningProp) continue; // if not emitted on owning side, inverse not emitted either + const prop = oneToManyPropName(other, usedNames); + if (!prop) continue; // collision -> skip + usedNames.add(prop); + // AGGREGATE CASCADE: child->parent FK ON DELETE CASCADE means parent owns children + // (aggregate) -> inverse @OneToMany ORM cascade:true (save parent persists children; + // audit #11: children were not persisted). RESTRICT/ + // SET_NULL -> independent relation, cascade NONE. + const cascade = (fk.OnDelete ?? "").toUpperCase() === "CASCADE"; + blocks.push(renderOneToMany(prop, other, owningProp, graph, imports, fromPath, cascade)); + } + } + + return blocks; +} + +/** Owning-side (@ManyToOne) property names this table EMITS, computed with EXACTLY + * the same algorithm as the owning-side loop: FK index -> property name. + * FKs not emitted (composite / other side not synthetic / collision) are NOT in map. + * Inverse (@OneToMany) uses this map -> + * both sides ALWAYS meet on same property name (deterministic bidirectional). */ +function owningRelationProps(table: CodeNode, graph: CodeGraph): Map { + const out = new Map(); + const cols = (propsOf<"Table">(table).Columns ?? []) as Column[]; + const used = new Set(cols.map((c) => c.Name)); + const fks = (propsOf<"Table">(table).ForeignKeys ?? []) as ForeignKey[]; + for (let i = 0; i < fks.length; i++) { + const fk = fks[i]; + if ((fk.Columns ?? []).length !== 1) continue; + const ref = resolveSyntheticEntity(fk.ReferencesTable, graph); + if (!ref || ref.id === table.id) continue; + const prop = manyToOnePropName(fk, ref, used); + if (!prop) continue; + used.add(prop); + out.set(i, prop); + } + return out; +} + +/** Owning-side @ManyToOne property name: strip "id"/"_id" suffix from FK column, + * camelCase. Empty or collision -> fall back to target table singular name; if that + * collides too -> null (relation skipped). Deterministic (name + given set only). */ +function manyToOnePropName(fk: ForeignKey, ref: CodeNode, used: Set): string | null { + const col = (fk.Columns ?? [])[0] ?? ""; + const stripped = col.replace(/_?[Ii]d$/, ""); + const candidates = [camelCase(stripped), camelCase(singularize(ref.name))]; + for (const c of candidates) { + if (c.length > 0 && !used.has(c)) return c; + } + return null; +} + +/** Inverse @OneToMany property name: plural-camelCase of owning table name. + * singularize then pluralize -> already-plural name not double-pluralized + * ("posts" -> "post" -> "posts"; "order_items" -> "order_item" -> "orderItems"). + * Collision -> null. Deterministic. */ +function oneToManyPropName(owning: CodeNode, used: Set): string | null { + const c = camelCase(pluralizeSnake(singularize(owning.name))); + if (c.length > 0 && !used.has(c)) return c; + return null; +} + +/** @ManyToOne(() => Ref, { eager: false }) + @JoinColumn({ name: }). */ +function renderManyToOne( + prop: string, + ref: CodeNode, + fkColumn: string, + nullable: boolean, + graph: CodeGraph, + imports: ImportCollector, + fromPath: string, +): string { + const refClass = entityClassNameForTable(ref); + imports.add("JoinColumn", "typeorm"); + imports.add("ManyToOne", "typeorm"); + imports.add(refClass, importPathOf(relativeImportPath(fromPath, synthEntityFilePath(ref, graph)))); + // Nullable FK -> optional relation; otherwise required (definite-assignment "!"). + const optional = nullable ? "?" : ""; + const assertion = optional ? "" : "!"; + const out: string[] = []; + out.push(` @ManyToOne(() => ${refClass}, { eager: false${nullable ? ", nullable: true" : ""} })`); + out.push(` @JoinColumn({ name: ${JSON.stringify(snakeCase(fkColumn))} })`); + out.push(` ${prop}${optional}${assertion}: ${refClass};`); + return out.join("\n"); +} + +/** @OneToMany(() => Other, (x) => x.). Collection -> definite-assignment + * "!" (compiles under strict). TypeORM forbids array initializer (= []) on relation + * properties (InitializedRelationError: breaks metadata build, migration/boot + * fails); so NO initializer, use "!" — same pattern as @ManyToOne. */ +function renderOneToMany( + prop: string, + other: CodeNode, + owningProp: string, + graph: CodeGraph, + imports: ImportCollector, + fromPath: string, + cascade: boolean, +): string { + const otherClass = entityClassNameForTable(other); + imports.add("OneToMany", "typeorm"); + imports.add(otherClass, importPathOf(relativeImportPath(fromPath, synthEntityFilePath(other, graph)))); + const param = inverseParamName(other); + // AGGREGATE (FK ON DELETE CASCADE) -> { cascade: true } (save parent persists children). + const opts = cascade ? ", { cascade: true }" : ""; + const out: string[] = []; + out.push(` @OneToMany(() => ${otherClass}, (${param}) => ${param}.${owningProp}${opts})`); + out.push(` ${prop}!: ${otherClass}[];`); + return out.join("\n"); +} + +/** Inverse function parameter name: singular-camelCase of other entity + * (e.g. "posts" -> "post"). Empty -> "x". */ +function inverseParamName(other: CodeNode): string { + const p = camelCase(singularize(other.name)); + return p.length > 0 ? p : "x"; +} + +/** Does a type token / ref name resolve to a SYNTHETIC entity Table? + * (Model-less + repository-referenced.) Returns Table node if yes; else null. */ +function resolveSyntheticEntity(refName: string, graph: CodeGraph): CodeNode | null { + if (typeof refName !== "string" || refName.length === 0) return null; + const node = graph.resolveRef("Table", refName); + if (!node || node.kindOf() !== "Table") return null; + return isSyntheticEntityTable(node, graph) ? node : null; +} + +/** Does this Table emit a SYNTHETIC entity? Same set as tablesNeedingSyntheticEntity + * (repo-referenced core + FK closure). SINGLE SOURCE. */ +function isSyntheticEntityTable(table: CodeNode, graph: CodeGraph): boolean { + return computeSyntheticEntityIds(graph).has(table.id); +} + +/** Prefer column named "id"; else first IsPrimaryKey column; else first column. */ +function pickPrimaryKey(columns: Column[]): Column | null { + const byId = columns.find((c) => c.Name.toLowerCase() === "id"); + if (byId) return byId; + const flagged = columns.find((c) => c.IsPrimaryKey === true); + if (flagged) return flagged; + return columns.length > 0 ? columns[0] : null; +} + +/** Single column -> decorated TypeORM field. ENUM column uses generated + * enum class as TS type (SAME as DTO) but @Column({ type: 'varchar' }) (no native + * enum — #56; migration also VARCHAR + CHECK). JSON -> Record + + * 'jsonb'. (sql-type-map SINGLE SOURCE.) */ +function renderColumn( + col: Column, + isPrimaryKey: boolean, + imports: ImportCollector, + graph: CodeGraph, + fromPath: string, +): string { + const tsType = entityColumnTsType(col, graph, imports, fromPath); + const out: string[] = []; + if (isPrimaryKey) { + imports.add("PrimaryGeneratedColumn", "typeorm"); + const isUuid = (col.DataType ?? "").toUpperCase() === "UUID"; + out.push(` @PrimaryGeneratedColumn(${isUuid ? '"uuid"' : ""})`); + out.push(` ${fieldDecl(col, true, tsType)}`); + return out.join("\n"); + } + imports.add("Column", "typeorm"); + out.push(` @Column(${columnOptions(col)})`); + out.push(` ${fieldDecl(col, false, tsType)}`); + return out.join("\n"); +} + +/** Column TS type (ENUM -> generated enum class + import; JSON -> + * Record; else sqlTypeToTs). */ +function entityColumnTsType( + col: Column, + graph: CodeGraph, + imports: ImportCollector, + fromPath: string, +): string { + return columnTsType(col.DataType, col.EnumRef, graph, (enumNode) => { + const cls = pascalCase(enumNode.name); + imports.add(cls, importPathOf(relativeImportPath(fromPath, filePathFor(enumNode, graph)))); + return cls; + }); +} + +/** @Column({ ... }) options (deterministic). #56: ENUM column becomes VARCHAR + * (no native Postgres enum) -> CONSISTENT with migration (also VARCHAR + CHECK). + * TS field type (entityColumnTsType) is still generated enum class; DB-level + * value constraint is in migration CHECK constraint. */ +function columnOptions(col: Column): string { + const parts: string[] = []; + const isEnumCol = (col.DataType ?? "").toUpperCase() === "ENUM"; + if (isEnumCol) { + parts.push(`type: "varchar"`); + } else { + parts.push(`type: ${JSON.stringify(columnOrmType(col.DataType))}`); + } + if (col.IsNotNull !== true) parts.push("nullable: true"); + if (col.IsUnique === true) parts.push("unique: true"); + return `{ ${parts.join(", ")} }`; +} + +/** TS field declaration: PK always required; else "?" when not NOT NULL. + * Required fields (no initializer) get definite-assignment "!" so compile under + * strict:true (strictPropertyInitialization) without TS2564 + * — TypeORM/class-validator standard. Optional "?" fields untouched. */ +function fieldDecl(col: Column, isPrimaryKey: boolean, tsType: string): string { + const optional = !isPrimaryKey && col.IsNotNull !== true ? "?" : ""; + const assertion = optional ? "" : "!"; + // TS member name camelCase (Id→id, CustomerId→customerId); DB column name derived + // separately as snakeCase + SnakeNamingStrategy maps member to same snake_case → no drift. + return `${tsPropName(col.Name)}${optional}${assertion}: ${tsType};`; +} diff --git a/apps/server/src/codegen/emitters/nestjs/enum.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/enum.emitter.spec.ts new file mode 100644 index 0000000..8b754b9 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/enum.emitter.spec.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from "vitest"; +import { emitEnum } from "./enum.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +function enumNode(properties: Record, id = "11111111-1111-4111-8111-111111111111"): StoredNode { + return { + id, + type: "Enum", + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function ctxFor(...nodes: StoredNode[]): { ctx: EmitterContext } { + const graph = buildCodeGraph(nodes, []); + return { ctx: { graph, target: "nestjs" } }; +} + +const ORDER_STATUS = { + Name: "OrderStatus", + Description: "Order status", + BackingType: "string", + Values: [ + { Key: "PENDING" }, + { Key: "SHIPPED", Value: "shipped", Description: "Handed to shipping" }, + { Key: "DELIVERED" }, + ], +}; + +describe("emitEnum (canonical reference emitter)", () => { + it("string backing — snapshot", () => { + const node = enumNode(ORDER_STATUS); + const { ctx } = ctxFor(node); + const [file] = emitEnum(ctx.graph.byId(node.id)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "/** Order status */ + export enum OrderStatus { + PENDING = "PENDING", + /** Handed to shipping */ + SHIPPED = "shipped", + DELIVERED = "DELIVERED", + } + ", + "language": "typescript", + "path": "common/enums/order-status.enum.ts", + "surgicalMarkers": 0, + } + `); + }); + + it("int backing — sequential + explicit value", () => { + const node = enumNode({ + Name: "Priority", + Description: "Priority", + BackingType: "int", + Values: [{ Key: "LOW" }, { Key: "MEDIUM", Value: "5" }, { Key: "HIGH" }], + }); + const { ctx } = ctxFor(node); + const [file] = emitEnum(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain("LOW = 0,"); + expect(file.content).toContain("MEDIUM = 5,"); + expect(file.content).toContain("HIGH = 6,"); + expect(file.language).toBe("typescript"); + }); + + it("file path kebab-case under common/enums", () => { + const node = enumNode(ORDER_STATUS); + const { ctx } = ctxFor(node); + const [file] = emitEnum(ctx.graph.byId(node.id)!, ctx); + expect(file.path).toBe("common/enums/order-status.enum.ts"); + }); + + it("content ends with single newline", () => { + const node = enumNode(ORDER_STATUS); + const { ctx } = ctxFor(node); + const [file] = emitEnum(ctx.graph.byId(node.id)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: same node twice -> byte-identical", () => { + const node = enumNode(ORDER_STATUS); + const { ctx } = ctxFor(node); + const a = emitEnum(ctx.graph.byId(node.id)!, ctx)[0].content; + const b = emitEnum(ctx.graph.byId(node.id)!, ctx)[0].content; + expect(a).toBe(b); + }); + + it("invalid member key is sanitized", () => { + const node = enumNode({ + Name: "WeirdEnum", + Description: "weird", + BackingType: "string", + Values: [{ Key: "1ST-PLACE" }, { Key: "OK GO" }], + }); + const { ctx } = ctxFor(node); + const [file] = emitEnum(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain("_1ST_PLACE = "); + expect(file.content).toContain("OK_GO = "); + }); + + /* ── STATE MACHINE (L2): Transitions -> transition map + canTransition + assert ─ + * When Transitions provided, emit allowed-transition map + canTransition + + * assertTransition (throws on illegal transition). Status-updating service + * uses this guard -> skips like pending->delivered are rejected. */ + it("Transitions -> transition map + canTransition + assert guards", () => { + const node = enumNode({ + Name: "OrderStatus", + Description: "Order status", + BackingType: "string", + Values: [ + { Key: "PENDING", Value: "pending" }, + { Key: "CONFIRMED", Value: "confirmed" }, + { Key: "DELIVERED", Value: "delivered" }, + { Key: "CANCELLED", Value: "cancelled" }, + ], + Transitions: [ + { From: "PENDING", To: ["CONFIRMED", "CANCELLED"] }, + { From: "CONFIRMED", To: ["DELIVERED", "CANCELLED"] }, + // DELIVERED, CANCELLED terminal (no transitions) -> not in map. + ], + }); + const { ctx } = ctxFor(node); + const [file] = emitEnum(ctx.graph.byId(node.id)!, ctx); + // Transition map (computed enum member keys). + expect(file.content).toMatch(/ORDER_STATUS_TRANSITIONS:\s*Partial enum only (no transition code emitted)", () => { + const node = enumNode({ + Name: "Color", + Description: "color", + BackingType: "string", + Values: [{ Key: "RED" }, { Key: "BLUE" }], + }); + const { ctx } = ctxFor(node); + const [file] = emitEnum(ctx.graph.byId(node.id)!, ctx); + expect(file.content).not.toContain("TRANSITIONS"); + expect(file.content).not.toContain("canTransition"); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/enum.emitter.ts b/apps/server/src/codegen/emitters/nestjs/enum.emitter.ts new file mode 100644 index 0000000..179ae47 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/enum.emitter.ts @@ -0,0 +1,141 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import { propsOf, type CodeNode } from "../../ir"; +import { filePathFor, pascalCase, snakeCase } from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers } from "../../surgical"; + +/* ──────────────────────────────────────────────────────────────────────── + * enum.emitter.ts — CANONICAL REFERENCE emitter. + * + * Other 10 emitter agents use this file as the template. Contract: + * - no default export; named `export const emitEnum: NodeEmitter`. + * - PURE function: (node, ctx) -> GeneratedFile[]. No I/O, no throw. + * - Path always via filePathFor(node, ctx.graph) (hardcode FORBIDDEN). + * - Content DETERMINISTIC: collections sorted, no timestamp/random. + * - imports via ImportCollector (Enum needs no imports but pattern holds). + * - surgicalMarkers counted with countSurgicalMarkers(content) (0 for Enum). + * - Content ends with single "\n". + * + * EnumNode -> common/enums/.enum.ts. BackingType: + * - "string": each member gets a string literal value (Key if Value absent). + * - "int": members get incrementing int from 0 (Value parsed if given; + * else previous+1 / sequential). Deterministic: Values order preserved. + * ──────────────────────────────────────────────────────────────────────── */ + +export const emitEnum: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = propsOf<"Enum">(node); + const enumName = pascalCase(node.name); + const backing = props.BackingType ?? "string"; + + // Enum generation needs no imports; collector set up to show the pattern. + const imports = new ImportCollector(); + + const lines: string[] = []; + + // Top doc comment (deterministic single block). + if (props.Description) { + lines.push(`/** ${props.Description} */`); + } + lines.push(`export enum ${enumName} {`); + + let intCounter = 0; + for (const v of props.Values) { + const key = sanitizeMemberKey(v.Key); + if (v.Description) lines.push(` /** ${v.Description} */`); + if (backing === "int") { + const intVal = resolveIntValue(v.Value, intCounter); + intCounter = intVal + 1; + lines.push(` ${key} = ${intVal},`); + } else { + const strVal = v.Value !== undefined && v.Value !== "" ? v.Value : v.Key; + lines.push(` ${key} = ${JSON.stringify(strVal)},`); + } + } + lines.push("}"); + + // ── STATE MACHINE (L2): when Transitions given, emit transition map + guards ── + lines.push(...emitTransitions(enumName, props)); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: filePathFor(node, ctx.graph), + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/** TS enum member name: invalid chars -> "_", numeric prefix gets leading "_". + * Deterministic. */ +function sanitizeMemberKey(raw: string): string { + let key = raw.replace(/[^A-Za-z0-9_$]/g, "_"); + if (key.length === 0) key = "_"; + if (/^[0-9]/.test(key)) key = `_${key}`; + return key; +} + +/** int backing value: parse Value as number when possible, else use sequential counter. */ +function resolveIntValue(value: string | undefined, fallback: number): number { + if (value !== undefined && value !== "") { + const parsed = Number(value); + if (Number.isInteger(parsed)) return parsed; + } + return fallback; +} + +/** STATE MACHINE (L2): when Transitions present, emit allowed-transition map + + * canTransition + assertTransition (throw on illegal transition). + * When absent returns empty array (enum stays pure). DETERMINISTIC: From/To sanitized + * to enum member Keys, only REAL members kept (tsc-safe), same From merged, + * output in Values order. Terminal states (no outgoing) omitted -> Partial. */ +function emitTransitions(enumName: string, props: ReturnType>): string[] { + const transitions = props.Transitions ?? []; + if (transitions.length === 0) return []; + + // Canonical member order (Values) + valid member set (tsc-safe references). + const order = props.Values.map((v) => sanitizeMemberKey(v.Key)); + const valid = new Set(order); + + // From -> To set (sanitize + valid members only; merge same From). + const byFrom = new Map>(); + for (const t of transitions) { + const from = sanitizeMemberKey(t.From); + if (!valid.has(from)) continue; + const tos = byFrom.get(from) ?? new Set(); + for (const raw of t.To) { + const to = sanitizeMemberKey(raw); + if (valid.has(to)) tos.add(to); + } + if (tos.size > 0) byFrom.set(from, tos); + } + if (byFrom.size === 0) return []; + + const constName = `${snakeCase(enumName).toUpperCase()}_TRANSITIONS`; + const out: string[] = []; + out.push(""); + out.push(`/** Allowed ${enumName} transitions (state machine). States not listed are terminal. */`); + out.push(`const ${constName}: Partial> = {`); + for (const from of order) { + const tos = byFrom.get(from); + if (!tos) continue; + const list = order.filter((k) => tos.has(k)).map((k) => `${enumName}.${k}`).join(", "); + out.push(` [${enumName}.${from}]: [${list}],`); + } + out.push("};"); + out.push(""); + out.push(`/** True if moving from \`from\` to \`to\` is a legal ${enumName} transition. */`); + out.push(`export function canTransition${enumName}(from: ${enumName}, to: ${enumName}): boolean {`); + out.push(` return ${constName}[from]?.includes(to) ?? false;`); + out.push("}"); + out.push(""); + out.push(`/** Throws if \`from\` -> \`to\` is not a legal ${enumName} transition (state-machine guard). */`); + out.push(`export function assert${enumName}Transition(from: ${enumName}, to: ${enumName}): void {`); + out.push(` if (!canTransition${enumName}(from, to)) {`); + out.push(" throw new Error(`Illegal " + enumName + " transition: ${from} -> ${to}`);"); + out.push(" }"); + out.push("}"); + return out; +} diff --git a/apps/server/src/codegen/emitters/nestjs/event-handler.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/event-handler.emitter.spec.ts new file mode 100644 index 0000000..0d4b814 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/event-handler.emitter.spec.ts @@ -0,0 +1,281 @@ +import { describe, it, expect } from "vitest"; +import { emitEventHandler } from "./event-handler.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; +import type { EdgeKind } from "../../../edges/schemas/edge.schema"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +const PROJECT = "00000000-0000-4000-8000-000000000000"; +const TAB = "22222222-2222-4222-8222-222222222222"; + +function node(type: StoredNode["type"], id: string, properties: Record): StoredNode { + return { + id, + type, + projectId: PROJECT, + positionX: 0, + positionY: 0, + homeTabId: TAB, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function edge(id: string, kind: EdgeKind, sourceNodeId: string, targetNodeId: string): StoredEdge { + return { + id, + projectId: PROJECT, + sourceNodeId, + targetNodeId, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: false }, + }; +} + +function ctxFrom(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { + return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; +} + +/* ── ID'ler ─────────────────────────────────────────────────────────────── */ +const HANDLER = "10000000-0000-4000-8000-000000000001"; +const QUEUE = "10000000-0000-4000-8000-000000000002"; +const SVC = "10000000-0000-4000-8000-000000000003"; +const CACHE = "10000000-0000-4000-8000-000000000004"; + +/* ── Node fixtures ──────────────────────────────────────────────────── */ +const imageQueue = node("MessageQueue", QUEUE, { + QueueName: "ImageJobsQueue", + Description: "Gorsel isleme kuyrugu", + Type: "Queue", + Provider: "RabbitMQ", + MessageFormat: "ImageJobDto", +}); + +const imageService = node("Service", SVC, { + ServiceName: "ImageService", + Description: "Image business logic", + IsTransactionScoped: false, + Dependencies: [], + Methods: [ + { + MethodName: "process", + Visibility: "public", + Parameters: [], + ReturnType: "void", + IsAsync: true, + Throws: [], + }, + ], +}); + +const imageCache = node("Cache", CACHE, { + CacheName: "ImageCache", +}); + +// Kuyruk-tabanli handler: ImageJobsQueue'yu dinler, ImageService'i cagirir. +const queueHandler = node("EventHandler", HANDLER, { + HandlerName: "ImageJobEventHandler", + Description: "Gorsel isleme job'unu tuketir", + EventName: "image.job.created", + IsAsync: true, + QueueRef: "ImageJobsQueue", + RetryPolicy: { MaxRetries: 3, DelaySeconds: 10 }, + DeadLetterQueue: "ImageJobsDLQ", +}); + +// Olay-tabanli handler: kuyruk NONE, sadece bir olay dinler. +const eventHandler = node("EventHandler", HANDLER, { + HandlerName: "OrderCreatedEventHandler", + Description: "Order olusturuldugunda tetiklenir", + EventName: "order.created", + IsAsync: false, +}); + +describe("emitEventHandler", () => { + it("kuyruk-tabanli (BullMQ @Processor) — snapshot", () => { + const ctx = ctxFrom( + [queueHandler, imageQueue, imageService, imageCache], + [ + edge("e-sub", "SUBSCRIBES", HANDLER, QUEUE), + edge("e-calls-svc", "CALLS", HANDLER, SVC), + edge("e-calls-cache", "CALLS", HANDLER, CACHE), + ], + ); + const [file] = emitEventHandler(ctx.graph.byId(HANDLER)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { Processor, WorkerHost } from "@nestjs/bullmq"; + import { Injectable } from "@nestjs/common"; + import type { Job } from "bullmq"; + import { ImageService } from "../image/image.service"; + import { ImageCache } from "./image.cache"; + + /** Gorsel isleme job'unu tuketir */ + /** retry: maxRetries=3, delaySeconds=10 */ + /** dead-letter-queue: ImageJobsDLQ */ + @Processor("ImageJobsQueue") + export class ImageJobEventHandler extends WorkerHost { + constructor( + private readonly imageCache: ImageCache, + private readonly imageService: ImageService, + ) { + super(); + } + + async process(job: Job): Promise { + // @solarch:surgical id=10000000-0000-4000-8000-000000000001#process + // Gorsel isleme job'unu tuketir + // Triggering queue: ImageJobsQueue. + // deps: this.imageCache, this.imageService + throw new Error("NOT_IMPLEMENTED: ImageJobEventHandler.process"); + } + } + ", + "language": "typescript", + "path": "common/image-job.handler.ts", + "surgicalMarkers": 1, + } + `); + }); + + it("olay-tabanli (@OnEvent) — snapshot", () => { + const ctx = ctxFrom( + [eventHandler, imageService], + [edge("e-calls-svc", "CALLS", HANDLER, SVC)], + ); + const [file] = emitEventHandler(ctx.graph.byId(HANDLER)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { Injectable } from "@nestjs/common"; + import { OnEvent } from "@nestjs/event-emitter"; + import { ImageService } from "./image.service"; + + /** Order olusturuldugunda tetiklenir */ + @Injectable() + export class OrderCreatedEventHandler { + constructor( + private readonly imageService: ImageService, + ) {} + + @OnEvent("order.created") + handleOrderCreated(payload: unknown): void { + // @solarch:surgical id=10000000-0000-4000-8000-000000000001#handleOrderCreated + // Order olusturuldugunda tetiklenir + // Triggering event: order.created. + // deps: this.imageService + throw new Error("NOT_IMPLEMENTED: OrderCreatedEventHandler.handleOrderCreated"); + } + } + ", + "language": "typescript", + "path": "image/order-created.handler.ts", + "surgicalMarkers": 1, + } + `); + }); + + it("QueueRef property'si (SUBSCRIBES edge yoksa) da kuyruk-tabanli kola duser", () => { + const ctx = ctxFrom([queueHandler, imageQueue], []); + const [file] = emitEventHandler(ctx.graph.byId(HANDLER)!, ctx); + expect(file.content).toContain('@Processor("ImageJobsQueue")'); + expect(file.content).toContain("extends WorkerHost"); + expect(file.content).toContain("import { Processor, WorkerHost } from \"@nestjs/bullmq\";"); + }); + + it("kuyruk cozulemezse (QueueRef + edge yok) olay-tabanli kola duser", () => { + const orphanQueueHandler = node("EventHandler", HANDLER, { + HandlerName: "GhostHandler", + Description: "Kayip kuyruk referansi", + EventName: "ghost.event", + IsAsync: true, + QueueRef: "NonExistentQueue", + }); + const ctx = ctxFrom([orphanQueueHandler], []); + const [file] = emitEventHandler(ctx.graph.byId(HANDLER)!, ctx); + expect(file.content).not.toContain("@Processor"); + expect(file.content).toContain('@OnEvent("ghost.event")'); + expect(file.content).toContain("@Injectable()"); + }); + + it("CALLS hedefi handle/process metodunda surgical marker + NOT_IMPLEMENTED", () => { + const ctx = ctxFrom([eventHandler, imageService], [edge("e", "CALLS", HANDLER, SVC)]); + const [file] = emitEventHandler(ctx.graph.byId(HANDLER)!, ctx); + expect(file.surgicalMarkers).toBe(1); + expect(file.content).toContain("// @solarch:surgical id="); + expect(file.content).toContain("// deps: this.imageService"); + expect(file.content).toContain( + 'throw new Error("NOT_IMPLEMENTED: OrderCreatedEventHandler.handleOrderCreated");', + ); + }); + + it("IsAsync -> Promise + async; sync -> void", () => { + const asyncCtx = ctxFrom([queueHandler, imageQueue], []); + const asyncFile = emitEventHandler(asyncCtx.graph.byId(HANDLER)!, asyncCtx)[0]; + expect(asyncFile.content).toContain("async process(job: Job): Promise {"); + + const syncCtx = ctxFrom([eventHandler], []); + const syncFile = emitEventHandler(syncCtx.graph.byId(HANDLER)!, syncCtx)[0]; + expect(syncFile.content).toContain("handleOrderCreated(payload: unknown): void {"); + expect(syncFile.content).not.toContain("async handleOrderCreated"); + }); + + it("dosya yolu filePathFor ile (.handler.ts, rol son-eki tekrarsiz)", () => { + // Handler ImageService'i cagirir -> feature-inference onu "image" feature'ina + // yerlestirir. baseNameOf("OrderCreatedEventHandler") -> "OrderCreated" -> + // dosya kok adi "order-created", rol son-eki ("EventHandler") TEKRARLANMAZ. + const ctx = ctxFrom([eventHandler, imageService], [edge("e", "CALLS", HANDLER, SVC)]); + const [file] = emitEventHandler(ctx.graph.byId(HANDLER)!, ctx); + expect(file.path).toBe("image/order-created.handler.ts"); + }); + + it("dep yoksa constructor uretilmez (bos DI)", () => { + const ctx = ctxFrom([eventHandler], []); + const [file] = emitEventHandler(ctx.graph.byId(HANDLER)!, ctx); + expect(file.content).not.toContain("constructor("); + }); + + it("DEDUP: ayni Service'e iki CALLS edge -> tek DI alani", () => { + const ctx = ctxFrom( + [eventHandler, imageService], + [edge("e1", "CALLS", HANDLER, SVC), edge("e2", "CALLS", HANDLER, SVC)], + ); + const [file] = emitEventHandler(ctx.graph.byId(HANDLER)!, ctx); + const occurrences = file.content.split("private readonly imageService").length - 1; + expect(occurrences).toBe(1); + }); + + it("content ends with single newline", () => { + const ctx = ctxFrom([eventHandler], []); + const [file] = emitEventHandler(ctx.graph.byId(HANDLER)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: two independent graph builds -> byte-identical", () => { + const nodes = [queueHandler, imageQueue, imageService, imageCache]; + const edges = [ + edge("e-sub", "SUBSCRIBES", HANDLER, QUEUE), + edge("e-calls-svc", "CALLS", HANDLER, SVC), + edge("e-calls-cache", "CALLS", HANDLER, CACHE), + ]; + const a = emitEventHandler(ctxFrom(nodes, edges).graph.byId(HANDLER)!, ctxFrom(nodes, edges)).at(0)!.content; + const b = emitEventHandler(ctxFrom(nodes, edges).graph.byId(HANDLER)!, ctxFrom(nodes, edges)).at(0)!.content; + expect(a).toBe(b); + }); + + it("edge-case: hic edge/kuyruk yok — throw etmez, minimal @OnEvent handler", () => { + const ctx = ctxFrom([eventHandler], []); + let file: { content: string; surgicalMarkers: number } | undefined; + expect(() => { + file = emitEventHandler(ctx.graph.byId(HANDLER)!, ctx)[0]; + }).not.toThrow(); + expect(file!.surgicalMarkers).toBe(1); + expect(file!.content).toContain("@OnEvent"); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/event-handler.emitter.ts b/apps/server/src/codegen/emitters/nestjs/event-handler.emitter.ts new file mode 100644 index 0000000..9a0e511 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/event-handler.emitter.ts @@ -0,0 +1,264 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import { type CodeGraph, type CodeNode } from "../../ir"; +import { + camelCase, + filePathFor, + importPathOf, + pascalCase, + relativeImportPath, +} from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers, notImplemented, surgicalMarker } from "../../surgical"; +import { stubClassName } from "./stub.emitter"; +import type { EventHandlerNode, NodeKind } from "../../../nodes/schemas"; + +/* ──────────────────────────────────────────────────────────────────────── + * event-handler.emitter.ts — EventHandlerNode -> /.handler.ts. + * + * An EventHandler emits ONE of two forms (deterministic choice): + * + * 1) QUEUE-BASED (when listening to a MessageQueue): BullMQ @Processor. + * - When handler connects to MessageQueue via SUBSCRIBES edge (or QueueRef + * property), emit @Processor("") + WorkerHost (process(job)). + * RetryPolicy/DeadLetterQueue documented in comment (BullMQ defaultJobOptions + * set at module registration). + * + * 2) EVENT-BASED (no queue): @nestjs/event-emitter @OnEvent(""). + * - Single handler method (@OnEvent) listening to EventName. + * + * Both forms carry surgical marker + NOT_IMPLEMENTED body to call Service via + * CALLS (DI fields this.). IsAsync -> Promise + async; else void. + * + * PURE + DETERMINISTIC: collections sorted by name, imports via + * ImportCollector, no timestamp/random, content ends with single "\n". + * Missing refs NEVER THROW — unresolved ref derived from raw name. + * ──────────────────────────────────────────────────────────────────────── */ + +/** DI kinds an EventHandler may depend on via CALLS. Service is first-class; + * Repository/Cache/ExternalService also injectable (real emitter Service/Repository, + * stub Cache/ExternalService). */ +const INJECTABLE_KINDS: NodeKind[] = ["Service", "Repository", "Cache", "ExternalService"]; + +/** Kinds with full (real) provider emitters — export class as `pascalCase(name)`. + * Cache + ExternalService now have full emitters (real class, no Stub suffix). + * Kept in sync with service.emitter / ir.ts. */ +const FULL_EMITTER_KINDS: ReadonlySet = new Set([ + "Service", + "Repository", + "Cache", + "ExternalService", +]); + +/** Resolved DI dependency: field name + class type + (optional) import path. */ +interface ResolvedDep { + /** constructor + `this.` */ + field: string; + /** injected class type */ + className: string; + /** resolved node file path (for import); null when unresolved */ + filePath: string | null; +} + +export const emitEventHandler: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = node.properties as EventHandlerNode["properties"]; + const graph = ctx.graph; + const className = pascalCase(node.name); + const filePath = filePathFor(node, graph); + + const imports = new ImportCollector(); + imports.add("Injectable", "@nestjs/common"); + + // ── DI dependencies (CALLS targets): handler delegates work to Service ── + const deps = collectDependencies(node, graph); + for (const dep of deps) { + if (dep.filePath) { + imports.add(dep.className, importPathOf(relativeImportPath(filePath, dep.filePath))); + } + } + const depFields = deps.map((d) => `this.${d.field}`); + + // ── Listened queue: SUBSCRIBES edge (else QueueRef property) ────── + const queue = resolveSubscribedQueue(node, graph); + + const isAsync = props.IsAsync; + const returnType = isAsync ? "Promise" : "void"; + const asyncKw = isAsync ? "async " : ""; + + const lines: string[] = []; + if (props.Description) lines.push(`/** ${props.Description} */`); + + if (queue) { + // ── (1) QUEUE-BASED: BullMQ @Processor + WorkerHost.process ────────── + const queueName = queueRegistrationName(queue); + imports.add("Processor", "@nestjs/bullmq"); + imports.add("WorkerHost", "@nestjs/bullmq"); + imports.addType("Job", "bullmq"); + + // RetryPolicy / DeadLetterQueue docs (BullMQ defaultJobOptions in module). + const retry = props.RetryPolicy; + if (retry) { + const delay = retry.DelaySeconds !== undefined ? `, delaySeconds=${retry.DelaySeconds}` : ""; + lines.push(`/** retry: maxRetries=${retry.MaxRetries}${delay} */`); + } + if (props.DeadLetterQueue) { + lines.push(`/** dead-letter-queue: ${props.DeadLetterQueue} */`); + } + + lines.push(`@Processor(${JSON.stringify(queueName)})`); + lines.push(`export class ${className} extends WorkerHost {`); + + // Derived class (WorkerHost) -> constructor must call super() first + // (TS: "Constructors for derived classes must contain a 'super' call"). + pushConstructor(lines, deps, /* superCall */ true); + if (deps.length > 0) lines.push(""); + + const marker = surgicalMarker({ + nodeId: node.id, + member: "process", + description: bodyDescription(props, queue, /* event */ undefined), + deps: depFields.length > 0 ? depFields : undefined, + }); + pushMethod(lines, `${asyncKw}process(job: Job): ${returnType}`, marker, className, "process"); + + lines.push("}"); + } else { + // ── (2) EVENT-BASED: @nestjs/event-emitter @OnEvent ──────────────────── + imports.add("OnEvent", "@nestjs/event-emitter"); + + lines.push("@Injectable()"); + lines.push(`export class ${className} {`); + + pushConstructor(lines, deps); + if (deps.length > 0) lines.push(""); + + const member = camelCase(props.EventName ? `handle ${props.EventName}` : `handle ${node.name}`); + const marker = surgicalMarker({ + nodeId: node.id, + member, + description: bodyDescription(props, /* queue */ undefined, props.EventName), + deps: depFields.length > 0 ? depFields : undefined, + }); + + const onEventLines: string[] = []; + onEventLines.push(` @OnEvent(${JSON.stringify(props.EventName ?? node.name)})`); + onEventLines.push(` ${asyncKw}${member}(payload: unknown): ${returnType} {`); + for (const ml of marker.split("\n")) onEventLines.push(` ${ml}`); + onEventLines.push(` ${notImplemented(className, member)}`); + onEventLines.push(" }"); + for (const l of onEventLines) lines.push(l); + + lines.push("}"); + } + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: filePath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/* ── Helpers ─────────────────────────────────────────────────────────── */ + +/** Append constructor block to `lines`. When `superCall` true (derived class, + * e.g. WorkerHost) emit `super()` in body -> constructor even with no deps + * (super required). superCall false + no deps -> no constructor. */ +function pushConstructor(lines: string[], deps: ResolvedDep[], superCall = false): void { + if (deps.length === 0 && !superCall) return; + lines.push(" constructor("); + for (const dep of deps) { + lines.push(` private readonly ${dep.field}: ${dep.className},`); + } + // Write super() in body when required; else empty `{}`. + if (superCall) { + lines.push(" ) {"); + lines.push(" super();"); + lines.push(" }"); + } else { + lines.push(" ) {}"); + } +} + +/** Append one method block (signature + surgical body) to `lines`. */ +function pushMethod( + lines: string[], + signature: string, + marker: string, + className: string, + member: string, +): void { + lines.push(` ${signature} {`); + for (const ml of marker.split("\n")) lines.push(` ${ml}`); + lines.push(` ${notImplemented(className, member)}`); + lines.push(" }"); +} + +/** Surgical body description: handler work + (optional) triggering queue/event. */ +function bodyDescription( + props: EventHandlerNode["properties"], + queue: CodeNode | undefined, + event: string | undefined, +): string { + const parts: string[] = []; + if (props.Description) parts.push(props.Description); + if (queue) parts.push(`Triggering queue: ${queue.name}.`); + else if (event) parts.push(`Triggering event: ${event}.`); + return parts.join("\n"); +} + +/** MessageQueue this handler listens to: SUBSCRIBES edge first (handler source), + * else QueueRef property. Undefined when unresolved (falls through to event-based). */ +function resolveSubscribedQueue(node: CodeNode, graph: CodeGraph): CodeNode | undefined { + for (const e of graph.outEdges(node.id, "SUBSCRIBES")) { + const tgt = graph.byId(e.targetNodeId); + if (tgt && tgt.kindOf() === "MessageQueue") return tgt; + } + const props = node.properties as EventHandlerNode["properties"]; + if (props.QueueRef && props.QueueRef.length > 0) { + const q = graph.resolveRef("MessageQueue", props.QueueRef); + if (q) return q; + } + return undefined; +} + +/** Queue name for BullMQ @Processor: MessageQueue.QueueName (resolved + * node name). Determinism: single source node.name. */ +function queueRegistrationName(queue: CodeNode): string { + return queue.name; +} + +/** DEDUP + name-sorted ResolvedDep list from CALLS targets + * (Service/Repository/Cache/ExternalService). Unresolved refs derive class from raw + * name (filePath=null -> skip import). Never throws. */ +function collectDependencies(node: CodeNode, graph: CodeGraph): ResolvedDep[] { + const byKey = new Map(); + for (const e of graph.outEdges(node.id, "CALLS")) { + const tgt = graph.byId(e.targetNodeId); + if (!tgt) continue; + if (!INJECTABLE_KINDS.includes(tgt.kindOf())) continue; + const key = tgt.name; + if (byKey.has(key)) continue; + byKey.set(key, { + field: camelCase(tgt.name), + className: injectedClassName(tgt), + filePath: filePathFor(tgt, graph), + }); + } + return [...byKey.values()].sort((a, b) => cmp(a.field, b.field)); +} + +/** Injected class name: full emitter kind -> `pascalCase(name)`; + * stubbed kind (Cache/ExternalService) -> `Stub` (SINGLE SOURCE via stub.emitter). */ +function injectedClassName(resolved: CodeNode): string { + if (FULL_EMITTER_KINDS.has(resolved.kindOf())) return pascalCase(resolved.name); + return stubClassName(resolved); +} + +/** Deterministic string compare. */ +function cmp(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} diff --git a/apps/server/src/codegen/emitters/nestjs/exception-synthesis.spec.ts b/apps/server/src/codegen/emitters/nestjs/exception-synthesis.spec.ts new file mode 100644 index 0000000..1226d83 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/exception-synthesis.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { buildCodeGraph } from "../../ir"; +import { + emitSyntheticException, + synthExceptionClassName, + synthExceptionFilePath, + undefinedThrownExceptions, +} from "./exception-synthesis"; +import type { StoredNode } from "../../../nodes/nodes.repository"; + +function node(type: StoredNode["type"], id: string, properties: Record): StoredNode { + return { + id, type, projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, positionY: 0, homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", version: 1, properties, + }; +} + +const method = (name: string, throws: string[]) => ({ + MethodName: name, Visibility: "public", Parameters: [], ReturnType: "void", IsAsync: true, Throws: throws, +}); + +describe("exception-synthesis — declared-but-undefined Throws", () => { + it("collects Throws WITHOUT Exception nodes; skips existing ones (dedup + sorted)", () => { + const svc1 = node("Service", "s1", { ServiceName: "OrderService", Methods: [ + method("PlaceOrder", ["CartEmptyException", "InsufficientStockException", "InvalidDiscountException"]), + method("Cancel", ["InvalidDiscountException"]), // dedup + ], Dependencies: [] }); + const svc2 = node("Service", "s2", { ServiceName: "PaymentService", Methods: [ + method("Pay", ["PaymentFailedException", "NotFoundException"]), + ], Dependencies: [] }); + // InsufficientStockException + NotFoundException are REAL nodes → should be skipped. + const exc1 = node("Exception", "e1", { ExceptionName: "InsufficientStockException", HttpStatusCode: 409, LogSeverity: "Warning" }); + const exc2 = node("Exception", "e2", { ExceptionName: "NotFoundException", HttpStatusCode: 404, LogSeverity: "Warning" }); + const graph = buildCodeGraph([svc1, svc2, exc1, exc2], []); + expect(undefinedThrownExceptions(graph)).toEqual([ + "CartEmptyException", "InvalidDiscountException", "PaymentFailedException", + ]); + }); + + it("single source name/path: pascalCase class + common/exceptions/.exception.ts", () => { + expect(synthExceptionClassName("CartEmptyException")).toBe("CartEmptyException"); + expect(synthExceptionFilePath("CartEmptyException")).toBe("common/exceptions/cart-empty.exception.ts"); + expect(synthExceptionFilePath("RefundFailedException")).toBe("common/exceptions/refund-failed.exception.ts"); + }); + + it("generated class: HttpException subclass + optional message + code (compilable)", () => { + const f = emitSyntheticException("CartEmptyException"); + expect(f.path).toBe("common/exceptions/cart-empty.exception.ts"); + expect(f.content).toContain("export class CartEmptyException extends HttpException {"); + expect(f.content).toContain("constructor(message ="); + expect(f.content).toContain('code: "CART_EMPTY"'); + expect(f.content).toContain("HttpStatus.BAD_REQUEST"); + expect(f.content).toContain('import { HttpException, HttpStatus } from "@nestjs/common";'); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/exception-synthesis.ts b/apps/server/src/codegen/emitters/nestjs/exception-synthesis.ts new file mode 100644 index 0000000..3e001dc --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/exception-synthesis.ts @@ -0,0 +1,89 @@ +import type { GeneratedFile } from "../../types"; +import { propsOf, type CodeGraph } from "../../ir"; +import { kebabCase, pascalCase, splitWords } from "../../naming"; + +/* ──────────────────────────────────────────────────────────────────────── + * exception-synthesis.ts — exception SYNTHESIS for declared-but-undefined Throws. + * + * Stitch (same family as entity-synthesis): a Service method declares `Throws=[X]` + * but no Exception node named X exists in the graph. service.emitter writes a surgical + * marker (`// throws: X`); fill's checkContract forces throwing X + * (declared-throws realization) → fill emits `throw new X(...)` → but the class is + * neither generated nor imported → TS2304. So fill HONORS the contract; it is told + * to throw an exception with no Constructor. + * + * Fix: for each declared-but-undefined Throws entry, emit a minimal HttpException + * subclass (same shape as real exception.emitter output: code+message+ + * status). Then the contract COMPILES. Class name/path is SINGLE SOURCE (synthException*) + * — service.emitter imports and this emitter bind to the SAME symbol/file. + * + * PURE + DETERMINISTIC: graph read only, sorted by name, no side effects. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Export class name for synthesized exception (pascalCase) — SINGLE SOURCE. */ +export function synthExceptionClassName(name: string): string { + return pascalCase(name); +} + +/** Project-relative file path for synthesized exception — SINGLE SOURCE. + * Same pattern as real exception.emitter (common feature): strip "Exception"/"Error" + * suffix, kebab + common/exceptions/.exception.ts. */ +export function synthExceptionFilePath(name: string): string { + return `common/exceptions/${kebabCase(stripExceptionSuffix(name))}.exception.ts`; +} + +/** "CartEmptyException"/"FooError" -> body name ("CartEmpty"/"Foo"); unchanged if no suffix. */ +function stripExceptionSuffix(name: string): string { + for (const suf of ["Exception", "Error"]) { + if (name.length > suf.length && name.toLowerCase().endsWith(suf.toLowerCase())) { + return name.slice(0, name.length - suf.length); + } + } + return name; +} + +/** Exception names declared in a Service method's Throws but not resolved to ANY + * Exception node (DEDUP + sorted by name). Synthetic classes are emitted for these; + * otherwise the fill contract (declared-throws) will not compile. */ +export function undefinedThrownExceptions(graph: CodeGraph): string[] { + const names = new Set(); + for (const svc of graph.allOf("Service")) { + for (const m of propsOf<"Service">(svc).Methods ?? []) { + for (const exName of m.Throws ?? []) { + if (typeof exName !== "string" || exName.length === 0) continue; + if (graph.resolveRef("Exception", exName)) continue; // real node exists → emitter produces it + names.add(exName); + } + } + } + return [...names].sort(); +} + +/** Emit a single synthetic exception class file (HttpException subclass, + * BAD_REQUEST default; optional message). Compatible with real exception.emitter shape — + * adding an Exception node to the diagram naturally replaces this. */ +export function emitSyntheticException(name: string): GeneratedFile { + const className = synthExceptionClassName(name); + const code = splitWords(stripExceptionSuffix(name)).map((w) => w.toUpperCase()).join("_") || "ERROR"; + const lines = [ + `import { HttpException, HttpStatus } from "@nestjs/common";`, + "", + `/**`, + ` * Solarch-synthesized exception — declared in a method's Throws but no Exception`, + ` * node defined it in the diagram. Generated so the contract (the surgical body`, + ` * throws it) compiles. Add an Exception node named "${className}" to set a`, + ` * specific HTTP status / error code.`, + ` */`, + `export class ${className} extends HttpException {`, + ` constructor(message = ${JSON.stringify(className)}) {`, + ` super({ code: ${JSON.stringify(code)}, message }, HttpStatus.BAD_REQUEST);`, + ` }`, + `}`, + ]; + return { + path: synthExceptionFilePath(name), + content: lines.join("\n") + "\n", + language: "typescript", + surgicalMarkers: 0, + }; +} diff --git a/apps/server/src/codegen/emitters/nestjs/exception.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/exception.emitter.spec.ts new file mode 100644 index 0000000..e70c3a6 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/exception.emitter.spec.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from "vitest"; +import { emitException } from "./exception.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +function exceptionNode( + properties: Record, + id = "11111111-1111-4111-8111-111111111111", +): StoredNode { + return { + id, + type: "Exception", + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function ctxFor(...nodes: StoredNode[]): { ctx: EmitterContext } { + const graph = buildCodeGraph(nodes, []); + return { ctx: { graph, target: "nestjs" } }; +} + +const USER_NOT_FOUND = { + ExceptionName: "UserNotFoundException", + Description: "Requested user not found", + HttpStatusCode: 404, + LogSeverity: "Warning", + ErrorCode: "ERR_USER_NOT_FOUND", +}; + +describe("emitException", () => { + it("HttpException-based — snapshot", () => { + const node = exceptionNode(USER_NOT_FOUND); + const { ctx } = ctxFor(node); + const [file] = emitException(ctx.graph.byId(node.id)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { HttpException, HttpStatus } from "@nestjs/common"; + + /** Requested user not found */ + export class UserNotFoundException extends HttpException { + static readonly httpStatus = HttpStatus.NOT_FOUND; + static readonly errorCode = "ERR_USER_NOT_FOUND"; + static readonly logSeverity = "Warning"; + + constructor(message?: string) { + super({ code: "ERR_USER_NOT_FOUND", message: message ?? "Requested user not found" }, HttpStatus.NOT_FOUND); + } + } + ", + "language": "typescript", + "path": "common/exceptions/user-not-found.exception.ts", + "surgicalMarkers": 0, + } + `); + }); + + it("file path under kebab-case common/exceptions", () => { + const node = exceptionNode(USER_NOT_FOUND); + const { ctx } = ctxFor(node); + const [file] = emitException(ctx.graph.byId(node.id)!, ctx); + expect(file.path).toBe("common/exceptions/user-not-found.exception.ts"); + }); + + it("import'lar @nestjs/common'dan HttpException + HttpStatus (alfabetik)", () => { + const node = exceptionNode(USER_NOT_FOUND); + const { ctx } = ctxFor(node); + const [file] = emitException(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain( + 'import { HttpException, HttpStatus } from "@nestjs/common";', + ); + }); + + it("HttpStatusCode -> correct HttpStatus member", () => { + const node = exceptionNode({ ...USER_NOT_FOUND, HttpStatusCode: 409 }); + const { ctx } = ctxFor(node); + const [file] = emitException(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain("static readonly httpStatus = HttpStatus.CONFLICT;"); + expect(file.content).toContain(", HttpStatus.CONFLICT);"); + }); + + it("unknown HttpStatusCode -> falls back to cast", () => { + const node = exceptionNode({ ...USER_NOT_FOUND, HttpStatusCode: 499 }); + const { ctx } = ctxFor(node); + const [file] = emitException(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain("(499 as HttpStatus)"); + }); + + it("when ParentExceptionRef resolves extends that class + relative import", () => { + const parent = exceptionNode( + { + ExceptionName: "AppException", + Description: "Application base error", + HttpStatusCode: 500, + LogSeverity: "Error", + }, + "33333333-3333-4333-8333-333333333333", + ); + const child = exceptionNode( + { + ExceptionName: "UserNotFoundException", + Description: "Requested user not found", + HttpStatusCode: 404, + LogSeverity: "Warning", + ErrorCode: "ERR_USER_NOT_FOUND", + ParentExceptionRef: "AppException", + }, + "44444444-4444-4444-8444-444444444444", + ); + const { ctx } = ctxFor(parent, child); + const [file] = emitException(ctx.graph.byId(child.id)!, ctx); + expect(file.content).toContain( + "export class UserNotFoundException extends AppException {", + ); + expect(file.content).toContain( + 'import { AppException } from "./app.exception";', + ); + // HttpException no longer imported in inheritance. + expect(file.content).not.toContain("HttpException"); + }); + + it("without ErrorCode response contains only message + static errorCode skipped", () => { + const node = exceptionNode({ + ExceptionName: "ValidationException", + Description: "Invalid request", + HttpStatusCode: 400, + LogSeverity: "Info", + }); + const { ctx } = ctxFor(node); + const [file] = emitException(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain( + 'super({ message: message ?? "Invalid request" }, HttpStatus.BAD_REQUEST);', + ); + expect(file.content).not.toContain("static readonly errorCode"); + }); + + it("EDGE-CASE: missing ParentExceptionRef -> no THROW, falls back to HttpException + TODO", () => { + const node = exceptionNode({ + ...USER_NOT_FOUND, + ParentExceptionRef: "GhostException", + }); + const { ctx } = ctxFor(node); // parent graph'ta NONE + expect(() => emitException(ctx.graph.byId(node.id)!, ctx)).not.toThrow(); + const [file] = emitException(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain("extends HttpException {"); + expect(file.content).toContain('// TODO: ParentExceptionRef "GhostException"'); + expect(file.surgicalMarkers).toBe(0); + }); + + it("surgical marker NONE (constructor not algorithm field)", () => { + const node = exceptionNode(USER_NOT_FOUND); + const { ctx } = ctxFor(node); + const [file] = emitException(ctx.graph.byId(node.id)!, ctx); + expect(file.surgicalMarkers).toBe(0); + expect(file.content).not.toContain("@solarch:surgical"); + }); + + it("content ends with single newline", () => { + const node = exceptionNode(USER_NOT_FOUND); + const { ctx } = ctxFor(node); + const [file] = emitException(ctx.graph.byId(node.id)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: same node twice -> byte-identical", () => { + const node = exceptionNode(USER_NOT_FOUND); + const { ctx } = ctxFor(node); + const a = emitException(ctx.graph.byId(node.id)!, ctx)[0].content; + const b = emitException(ctx.graph.byId(node.id)!, ctx)[0].content; + expect(a).toBe(b); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/exception.emitter.ts b/apps/server/src/codegen/emitters/nestjs/exception.emitter.ts new file mode 100644 index 0000000..b27796a --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/exception.emitter.ts @@ -0,0 +1,168 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import { propsOf, type CodeNode } from "../../ir"; +import { filePathFor, pascalCase, relativeImportPath, importPathOf } from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers } from "../../surgical"; + +/* ──────────────────────────────────────────────────────────────────────── + * exception.emitter.ts — ExceptionNode -> common/exceptions/.exception.ts + * + * Generated class extends NestJS HttpException (or another Exception class + * resolved via ParentExceptionRef). Constructor has deterministic body — + * this is an "algorithm field" NOT, so no surgical marker + * (countSurgicalMarkers -> 0). + * + * constructor() { + * super({ code: "", message: "" }, HttpStatus.); + * } + * + * Inheritance: + * - If ParentExceptionRef is set and ctx.graph.resolveRef("Exception", ref) + * resolves a node -> extends that class, relative import. + * - otherwise -> extends HttpException (@nestjs/common). + * Missing ref -> NO THROW; silently falls back to HttpException (+ TODO comment). + * + * PURE function: (node, ctx) -> GeneratedFile[]. No I/O, no throw. + * Path always filePathFor; imports via ImportCollector; name pascalCase. + * Content ends with single "\n". + * ──────────────────────────────────────────────────────────────────────── */ + +export const emitException: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = propsOf<"Exception">(node); + const className = pascalCase(node.name); + const filePath = filePathFor(node, ctx.graph); + + const imports = new ImportCollector(); + + // HttpStatus always required (super's 2nd argument). + imports.add("HttpStatus", "@nestjs/common"); + const statusMember = httpStatusMember(props.HttpStatusCode); + + // ── Resolve parent class ────────────────────────────────────────────── + let parentClass = "HttpException"; + let missingParent = false; + const parentRef = props.ParentExceptionRef; + if (parentRef !== undefined && parentRef !== "") { + const parentNode = ctx.graph.resolveRef("Exception", parentRef); + if (parentNode) { + parentClass = pascalCase(parentNode.name); + const parentPath = filePathFor(parentNode, ctx.graph); + // Relative import when not same file (Exceptions live under common/exceptions + // so usually "./.exception"). + if (importPathOf(parentPath) !== importPathOf(filePath)) { + imports.add(parentClass, relativeImportPath(filePath, parentPath)); + } + } else { + // Missing ref -> fall back to HttpException, leave note for user. + missingParent = true; + } + } + + if (parentClass === "HttpException") { + imports.add("HttpException", "@nestjs/common"); + } + + // ── Body ─────────────────────────────────────────────────────────────── + const lines: string[] = []; + + if (props.Description) { + lines.push(`/** ${props.Description} */`); + } + lines.push(`export class ${className} extends ${parentClass} {`); + if (missingParent) { + lines.push(` // TODO: ParentExceptionRef "${parentRef}" could not be resolved; fell back to HttpException.`); + } + // In-class static fields (static, deterministic metadata). + lines.push(` static readonly httpStatus = ${statusMember};`); + if (props.ErrorCode !== undefined && props.ErrorCode !== "") { + lines.push(` static readonly errorCode = ${JSON.stringify(props.ErrorCode)};`); + } + lines.push(` static readonly logSeverity = ${JSON.stringify(props.LogSeverity)};`); + lines.push(""); + lines.push(" constructor(message?: string) {"); + lines.push(` super(${responseLiteral(props.ErrorCode, props.Description)}, ${statusMember});`); + lines.push(" }"); + lines.push("}"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: filePath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/** super()'s 1st argument: { code, message } response object. + * When ErrorCode absent, only message is written (deterministic). */ +function responseLiteral(errorCode: string | undefined, description: string): string { + // constructor(message?) -> use runtime message if present, else Description default. + const message = `message ?? ${JSON.stringify(description)}`; + if (errorCode !== undefined && errorCode !== "") { + return `{ code: ${JSON.stringify(errorCode)}, message: ${message} }`; + } + return `{ message: ${message} }`; +} + +/** HTTP status code (number) -> `HttpStatus.`. + * Known codes map to named member; unknown fall back to safe ` as HttpStatus` + * cast instead of `HttpStatus[]` (deterministic). */ +function httpStatusMember(code: number): string { + const name = HTTP_STATUS_NAMES[code]; + return name ? `HttpStatus.${name}` : `(${code} as HttpStatus)`; +} + +/** NestJS HttpStatus enum equivalents (deterministic constant table). */ +const HTTP_STATUS_NAMES: Record = { + 100: "CONTINUE", + 101: "SWITCHING_PROTOCOLS", + 102: "PROCESSING", + 103: "EARLYHINTS", + 200: "OK", + 201: "CREATED", + 202: "ACCEPTED", + 203: "NON_AUTHORITATIVE_INFORMATION", + 204: "NO_CONTENT", + 205: "RESET_CONTENT", + 206: "PARTIAL_CONTENT", + 300: "AMBIGUOUS", + 301: "MOVED_PERMANENTLY", + 302: "FOUND", + 303: "SEE_OTHER", + 304: "NOT_MODIFIED", + 307: "TEMPORARY_REDIRECT", + 308: "PERMANENT_REDIRECT", + 400: "BAD_REQUEST", + 401: "UNAUTHORIZED", + 402: "PAYMENT_REQUIRED", + 403: "FORBIDDEN", + 404: "NOT_FOUND", + 405: "METHOD_NOT_ALLOWED", + 406: "NOT_ACCEPTABLE", + 407: "PROXY_AUTHENTICATION_REQUIRED", + 408: "REQUEST_TIMEOUT", + 409: "CONFLICT", + 410: "GONE", + 411: "LENGTH_REQUIRED", + 412: "PRECONDITION_FAILED", + 413: "PAYLOAD_TOO_LARGE", + 414: "URI_TOO_LONG", + 415: "UNSUPPORTED_MEDIA_TYPE", + 416: "REQUESTED_RANGE_NOT_SATISFIABLE", + 417: "EXPECTATION_FAILED", + 418: "I_AM_A_TEAPOT", + 421: "MISDIRECTED", + 422: "UNPROCESSABLE_ENTITY", + 424: "FAILED_DEPENDENCY", + 428: "PRECONDITION_REQUIRED", + 429: "TOO_MANY_REQUESTS", + 500: "INTERNAL_SERVER_ERROR", + 501: "NOT_IMPLEMENTED", + 502: "BAD_GATEWAY", + 503: "SERVICE_UNAVAILABLE", + 504: "GATEWAY_TIMEOUT", + 505: "HTTP_VERSION_NOT_SUPPORTED", +}; diff --git a/apps/server/src/codegen/emitters/nestjs/external-service.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/external-service.emitter.spec.ts new file mode 100644 index 0000000..ce3e797 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/external-service.emitter.spec.ts @@ -0,0 +1,319 @@ +import { describe, it, expect } from "vitest"; +import { emitExternalService } from "./external-service.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +const PROJECT_ID = "00000000-0000-4000-8000-000000000000"; +const TAB_ID = "22222222-2222-4222-8222-222222222222"; + +function extNode( + properties: Record, + id = "11111111-1111-4111-8111-111111111111", +): StoredNode { + return { + id, + type: "ExternalService", + projectId: PROJECT_ID, + positionX: 0, + positionY: 0, + homeTabId: TAB_ID, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function serviceNode( + properties: Record, + id = "33333333-3333-4333-8333-333333333333", +): StoredNode { + return { + id, + type: "Service", + projectId: PROJECT_ID, + positionX: 0, + positionY: 0, + homeTabId: TAB_ID, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function callsEdge( + sourceNodeId: string, + targetNodeId: string, + id = "44444444-4444-4444-8444-444444444444", +): StoredEdge { + return { + id, + projectId: PROJECT_ID, + sourceNodeId, + targetNodeId, + kind: "CALLS", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: true }, + }; +} + +function ctxFor(nodes: StoredNode[], edges: StoredEdge[] = []): { ctx: EmitterContext } { + const graph = buildCodeGraph(nodes, edges); + return { ctx: { graph, target: "nestjs" } }; +} + +/** Tipik dis servis: Stripe-benzeri Bearer auth + iki endpoint. */ +const STRIPE = { + ServiceName: "StripeClient", + Description: "Stripe odeme API istemcisi", + BaseURL: "https://api.stripe.com", + AuthType: "Bearer", + TimeoutSeconds: 30, + Endpoints: [ + { Name: "CreateCharge", Method: "POST", Path: "/v1/charges" }, + { Name: "RefundCharge", Method: "POST", Path: "/v1/refunds" }, + ], +}; + +describe("emitExternalService", () => { + it("@Injectable HTTP client snapshot (gercek kod, stub degil)", () => { + const node = extNode(STRIPE); + const { ctx } = ctxFor([node]); + const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { HttpService } from "@nestjs/axios"; + import { Injectable } from "@nestjs/common"; + import { ConfigService } from "@nestjs/config"; + + /** Stripe odeme API istemcisi */ + @Injectable() + export class StripeClient { + private readonly baseUrl: string; + private readonly timeoutMs: number; + + constructor( + private readonly http: HttpService, + private readonly config: ConfigService, + ) { + this.baseUrl = this.config.get("STRIPE_CLIENT_BASE_URL") ?? ""; + this.timeoutMs = (this.config.get("STRIPE_CLIENT_TIMEOUT_SECONDS") ?? 30) * 1000; + } + + async createCharge(payload?: unknown): Promise { + // @solarch:surgical id=11111111-1111-4111-8111-111111111111#createCharge + // POST /v1/charges — external service call. Call this.authHeaders() for the headers. + // deps: this.http.post, this.baseUrl, this.timeoutMs, this.authHeaders() + // "/v1/charges" -> this.http.post(this.baseUrl + "/v1/charges") + throw new Error("NOT_IMPLEMENTED: StripeClient.createCharge"); + } + + async refundCharge(payload?: unknown): Promise { + // @solarch:surgical id=11111111-1111-4111-8111-111111111111#refundCharge + // POST /v1/refunds — external service call. Call this.authHeaders() for the headers. + // deps: this.http.post, this.baseUrl, this.timeoutMs, this.authHeaders() + // "/v1/refunds" -> this.http.post(this.baseUrl + "/v1/refunds") + throw new Error("NOT_IMPLEMENTED: StripeClient.refundCharge"); + } + + private authHeaders(): Record { + // @solarch:surgical id=11111111-1111-4111-8111-111111111111#authHeaders + // Bearer authentication headers (the secret is bound via ENV STRIPE_CLIENT_AUTH_TOKEN, never embedded in code). + // deps: this.config + // secret = this.config.get("STRIPE_CLIENT_AUTH_TOKEN"); // ENV binding — no raw secret + throw new Error("NOT_IMPLEMENTED: StripeClient.authHeaders"); + } + } + ", + "language": "typescript", + "path": "common/stripe.client.ts", + "surgicalMarkers": 3, + } + `); + }); + + it("sinif adi gercek (pascalCase), Stub eki NONE", () => { + const node = extNode(STRIPE); + const { ctx } = ctxFor([node]); + const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain("export class StripeClient {"); + expect(file.content).not.toContain("StripeClientStub"); + expect(file.content).not.toContain("Stub"); + }); + + it("HttpService + ConfigService inject edilir, HttpModule importu beklenir", () => { + const node = extNode(STRIPE); + const { ctx } = ctxFor([node]); + const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain('import { HttpService } from "@nestjs/axios";'); + expect(file.content).toContain('import { ConfigService } from "@nestjs/config";'); + expect(file.content).toContain("private readonly http: HttpService,"); + expect(file.content).toContain("private readonly config: ConfigService,"); + }); + + it("dosya yolu feature-aware (filePathFor -> /.client.ts)", () => { + const node = extNode(STRIPE); + const { ctx } = ctxFor([node]); + const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); + // baseNameOf("StripeClient") -> "Stripe"; referrer'siz standalone node + // feature-inference'ta "common"a duser (bir Service CALLS etmiyor). + expect(file.path).toBe("common/stripe.client.ts"); + expect(file.path.endsWith(".client.ts")).toBe(true); + }); + + it("feature atamasi: bir Service CALLS ederse o feature klasorune yazilir", () => { + const ext = extNode({ + ServiceName: "StableDiffusionApi", + Description: "Gorsel uretimi", + BaseURL: "https://sd.example.com", + AuthType: "API_Key", + TimeoutSeconds: 60, + Endpoints: [], + }); + const svc = serviceNode({ + ServiceName: "ImageGenerationService", + Description: "Gorsel ureten servis", + Methods: [], + Dependencies: [], + }); + const { ctx } = ctxFor([ext, svc], [callsEdge(svc.id, ext.id)]); + const [file] = emitExternalService(ctx.graph.byId(ext.id)!, ctx); + // baseNameOf("StableDiffusionApi") -> "StableDiffusion"; CALLS eden servis + // "image-generation" feature'inda -> ext de ayni feature'a duser. + expect(file.path).toBe("image-generation/stable-diffusion.client.ts"); + expect(file.content).toContain("export class StableDiffusionApi {"); + }); + + it("Endpoint yoksa tek generic request metodu uretir", () => { + const node = extNode({ + ServiceName: "MailService", + Description: "E-posta gonderimi", + BaseURL: "https://mail.example.com", + AuthType: "None", + TimeoutSeconds: 10, + Endpoints: [], + }); + const { ctx } = ctxFor([node]); + const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain( + "async request(method: string, path: string, payload?: unknown): Promise {", + ); + expect(file.content).toContain("NOT_IMPLEMENTED: MailService.request"); + // baseNameOf("MailService") -> "Mail" (Service eki duser); standalone -> + // common feature klasoru. + expect(file.path).toBe("common/mail.client.ts"); + }); + + it("Endpoint metotlari Name'e gore deterministik sirali (Zebra once mi sonra mi)", () => { + const node = extNode({ + ServiceName: "MultiApi", + Description: "cok uclu", + BaseURL: "https://m.example.com", + AuthType: "None", + TimeoutSeconds: 5, + Endpoints: [ + { Name: "Zebra", Method: "GET", Path: "/z" }, + { Name: "Alpha", Method: "GET", Path: "/a" }, + { Name: "Mango", Method: "GET", Path: "/m" }, + ], + }); + const { ctx } = ctxFor([node]); + const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); + const idxAlpha = file.content.indexOf("async alpha("); + const idxMango = file.content.indexOf("async mango("); + const idxZebra = file.content.indexOf("async zebra("); + expect(idxAlpha).toBeGreaterThan(-1); + expect(idxAlpha).toBeLessThan(idxMango); + expect(idxMango).toBeLessThan(idxZebra); + }); + + it("HTTP fiili HttpService metoduna eslenir (this.http.)", () => { + const node = extNode({ + ServiceName: "VerbApi", + Description: "fiiller", + BaseURL: "https://v.example.com", + AuthType: "None", + TimeoutSeconds: 5, + Endpoints: [ + { Name: "FetchThing", Method: "GET", Path: "/t" }, + { Name: "RemoveThing", Method: "DELETE", Path: "/t" }, + ], + }); + const { ctx } = ctxFor([node]); + const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain("this.http.get"); + expect(file.content).toContain("this.http.delete"); + }); + + it("AuthType=None -> authHeaders helper'i URETILMEZ", () => { + const node = extNode({ + ServiceName: "OpenApi", + Description: "auth yok", + BaseURL: "https://o.example.com", + AuthType: "None", + TimeoutSeconds: 5, + Endpoints: [{ Name: "Ping", Method: "GET", Path: "/ping" }], + }); + const { ctx } = ctxFor([node]); + const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); + expect(file.content).not.toContain("authHeaders"); + }); + + it("API_Key auth -> ENV binding ile API_KEY, RAW secret koda gomulmez", () => { + const node = extNode({ + ServiceName: "KeyedApi", + Description: "api key", + BaseURL: "https://k.example.com", + AuthType: "API_Key", + TimeoutSeconds: 5, + Endpoints: [], + }); + const { ctx } = ctxFor([node]); + const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain("private authHeaders(): Record {"); + expect(file.content).toContain("KEYED_API_API_KEY"); + // BaseURL bir literal olarak koda gomulmemeli (ENV binding ile okunur). + expect(file.content).not.toContain("https://k.example.com"); + }); + + it("BaseURL/Timeout ConfigService env-var binding ile okunur (literal gomulmez)", () => { + const node = extNode(STRIPE); + const { ctx } = ctxFor([node]); + const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain('this.config.get("STRIPE_CLIENT_BASE_URL")'); + expect(file.content).toContain('this.config.get("STRIPE_CLIENT_TIMEOUT_SECONDS")'); + expect(file.content).not.toContain("https://api.stripe.com"); + // Fallback olarak schema TimeoutSeconds kullanilir. + expect(file.content).toContain("?? 30) * 1000"); + }); + + it("surgicalMarkers govde gerektiren her metotta sayilir", () => { + const node = extNode(STRIPE); + const { ctx } = ctxFor([node]); + const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); + // 2 endpoint + 1 authHeaders = 3 marker. + expect(file.surgicalMarkers).toBe(3); + expect(file.content).toContain("@solarch:surgical"); + }); + + it("content ends with single newline", () => { + const node = extNode(STRIPE); + const { ctx } = ctxFor([node]); + const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: same node twice -> byte-identical", () => { + const node = extNode(STRIPE); + const { ctx } = ctxFor([node]); + const a = emitExternalService(ctx.graph.byId(node.id)!, ctx)[0].content; + const b = emitExternalService(ctx.graph.byId(node.id)!, ctx)[0].content; + expect(a).toBe(b); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/external-service.emitter.ts b/apps/server/src/codegen/emitters/nestjs/external-service.emitter.ts new file mode 100644 index 0000000..8c25c48 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/external-service.emitter.ts @@ -0,0 +1,218 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import type { CodeNode } from "../../ir"; +import { + camelCase, + filePathFor, + pascalCase, + snakeCase, +} from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers, notImplemented, surgicalMarker } from "../../surgical"; +import type { ExternalServiceNode } from "../../../nodes/schemas/external-service.schema"; + +/* ──────────────────────────────────────────────────────────────────────── + * external-service.emitter.ts — ExternalServiceNode -> /.client.ts. + * + * Emits an @Injectable() NestJS HTTP client wrapping an external HTTP service: + * - DI: HttpService (@nestjs/axios) + ConfigService (@nestjs/config). + * HttpModule + ConfigModule are wired by the module (Wire phase). + * - Config: BaseURL and TimeoutSeconds read from ConfigService via env-var binding + * (raw secret NONE). AuthType != "None" -> authHeaders() helper binds a + * Bearer/Basic/API_Key header from an ENV variable (label). + * - Methods: one method per schema Endpoints entry (if any) + * (HTTP verb + path); otherwise a single generic `request` method. Each method + * body is surgicalMarker + notImplemented + accessible dependency hint + * (this.http / this.baseUrl ...). + * + * PURE + DETERMINISTIC: Endpoints sorted by Name, imports via ImportCollector, + * no timestamp/random, content ends with single "\n". + * ──────────────────────────────────────────────────────────────────────── */ + +/** ExternalService props — PropsByKind (ir.ts) does not include this kind (backend + * chain carries 9 types); type comes directly from Zod-inferred schema. */ +type ExtProps = ExternalServiceNode["properties"]; +type ExtEndpoint = ExtProps["Endpoints"][number]; + +/** Narrow node.properties to typed ExternalService props (DB is already + * Zod-validated; no runtime conversion). */ +function extPropsOf(node: CodeNode): ExtProps { + return node.properties as ExtProps; +} + +/** HTTP verb -> HttpService method name (this.http.). */ +const HTTP_VERB: Record = { + GET: "get", + POST: "post", + PUT: "put", + DELETE: "delete", + PATCH: "patch", +}; + +export const emitExternalService: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = extPropsOf(node); + const className = pascalCase(node.name); + const filePath = filePathFor(node, ctx.graph); + // Env-var binding prefixes (SCREAMING_SNAKE) — no raw secret/URL embedded. + const envPrefix = snakeCase(node.name).toUpperCase(); + + const imports = new ImportCollector(); + imports.add("Injectable", "@nestjs/common"); + imports.add("HttpService", "@nestjs/axios"); + imports.add("ConfigService", "@nestjs/config"); + + const authType = props.AuthType ?? "None"; + const needsAuth = authType !== "None"; + + // ── Method blocks: Endpoints (sorted by Name) or generic request ── + const methodBlocks: string[] = []; + const endpoints = [...(props.Endpoints ?? [])].sort((a, b) => cmp(a.Name, b.Name)); + if (endpoints.length > 0) { + for (const ep of endpoints) { + methodBlocks.push(renderEndpointMethod(node, className, ep, needsAuth)); + } + } else { + methodBlocks.push(renderGenericRequest(node, className, needsAuth)); + } + if (needsAuth) { + methodBlocks.push(renderAuthHeaders(node, className, authType, envPrefix)); + } + + // ── Class body ── + const lines: string[] = []; + // Emit JSDoc when Description is meaningful (trim >=3 char); skip single-letter/empty noise. + if (isMeaningfulDoc(props.Description)) lines.push(`/** ${props.Description!.trim()} */`); + lines.push("@Injectable()"); + lines.push(`export class ${className} {`); + + // Config fields (resolved via env-var binding — no raw values embedded). + // ASSIGN in constructor BODY: field initializers run BEFORE constructor body; + // reading `this.config` in a field initializer causes TS2729 ("used before its + // initialization") + runtime `undefined`. Param-property assignments run at body + // start, so we move assignments into the body. + lines.push(" private readonly baseUrl: string;"); + lines.push(" private readonly timeoutMs: number;"); + lines.push(""); + + lines.push(" constructor("); + lines.push(" private readonly http: HttpService,"); + lines.push(" private readonly config: ConfigService,"); + lines.push(" ) {"); + lines.push(` this.baseUrl = this.config.get(${JSON.stringify(`${envPrefix}_BASE_URL`)}) ?? "";`); + lines.push(` this.timeoutMs = (this.config.get(${JSON.stringify(`${envPrefix}_TIMEOUT_SECONDS`)}) ?? ${props.TimeoutSeconds}) * 1000;`); + lines.push(" }"); + + if (methodBlocks.length > 0) lines.push(""); + methodBlocks.forEach((block, i) => { + lines.push(block); + if (i < methodBlocks.length - 1) lines.push(""); + }); + + lines.push("}"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: filePath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/** Convert a schema Endpoint (HTTP verb + path) into a client method. + * Method name: camelCase(Name). Body = surgical marker + notImplemented; + * marker hints accessible dependencies (this.http., this.baseUrl). */ +function renderEndpointMethod( + node: CodeNode, + className: string, + ep: ExtEndpoint, + needsAuth: boolean, +): string { + const indent = " "; + const methodName = camelCase(ep.Name); + const verb = HTTP_VERB[ep.Method] ?? "request"; + + // When AuthType != "None", authHeaders() helper IS emitted; explicitly remind + // surgical AI to call it in the request (otherwise helper stays unused -> + // noUnusedLocals/ESLint warning). + const deps = [`this.http.${verb}`, "this.baseUrl", "this.timeoutMs"]; + if (needsAuth) deps.push("this.authHeaders()"); + const marker = surgicalMarker({ + nodeId: node.id, + member: methodName, + description: needsAuth + ? `${ep.Method} ${ep.Path} — external service call. Call this.authHeaders() for the headers.` + : `${ep.Method} ${ep.Path} — external service call.`, + deps, + }); + + const lines: string[] = []; + lines.push(`${indent}async ${methodName}(payload?: unknown): Promise {`); + for (const ml of marker.split("\n")) lines.push(`${indent}${indent}${ml}`); + lines.push(`${indent}${indent}// ${JSON.stringify(`${ep.Path}`)} -> this.http.${verb}(this.baseUrl + ${JSON.stringify(ep.Path)})`); + lines.push(`${indent}${indent}${notImplemented(className, methodName)}`); + lines.push(`${indent}}`); + return lines.join("\n"); +} + +/** When no Endpoints defined: single generic request method. */ +function renderGenericRequest(node: CodeNode, className: string, needsAuth: boolean): string { + const indent = " "; + // When AuthType != "None", authHeaders() IS emitted -> must be called in request. + const deps = ["this.http.request", "this.baseUrl", "this.timeoutMs"]; + if (needsAuth) deps.push("this.authHeaders()"); + const marker = surgicalMarker({ + nodeId: node.id, + member: "request", + description: needsAuth + ? "Generic external service HTTP call (no endpoint defined). Call this.authHeaders() for the headers." + : "Generic external service HTTP call (no endpoint defined).", + deps, + }); + + const lines: string[] = []; + lines.push(`${indent}async request(method: string, path: string, payload?: unknown): Promise {`); + for (const ml of marker.split("\n")) lines.push(`${indent}${indent}${ml}`); + lines.push(`${indent}${indent}${notImplemented(className, "request")}`); + lines.push(`${indent}}`); + return lines.join("\n"); +} + +/** Auth header helper for AuthType != "None". SECRET value NEVER embedded — + * read via ENV variable binding (label); body is surgical. */ +function renderAuthHeaders( + node: CodeNode, + className: string, + authType: string, + envPrefix: string, +): string { + const indent = " "; + const envKey = authType === "API_Key" ? `${envPrefix}_API_KEY` : `${envPrefix}_AUTH_TOKEN`; + const marker = surgicalMarker({ + nodeId: node.id, + member: "authHeaders", + description: `${authType} authentication headers (the secret is bound via ENV ${envKey}, never embedded in code).`, + deps: ["this.config"], + }); + + const lines: string[] = []; + lines.push(`${indent}private authHeaders(): Record {`); + for (const ml of marker.split("\n")) lines.push(`${indent}${indent}${ml}`); + lines.push(`${indent}${indent}// secret = this.config.get(${JSON.stringify(envKey)}); // ENV binding — no raw secret`); + lines.push(`${indent}${indent}${notImplemented(className, "authHeaders")}`); + lines.push(`${indent}}`); + return lines.join("\n"); +} + +/** Deterministic string comparison. */ +function cmp(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** Whether a Description warrants a meaningful JSDoc: trim length >=3 char. + * Single-letter/empty descriptions are JSDoc noise; skipped. */ +function isMeaningfulDoc(desc: string | undefined): boolean { + return typeof desc === "string" && desc.trim().length >= 3; +} diff --git a/apps/server/src/codegen/emitters/nestjs/index.ts b/apps/server/src/codegen/emitters/nestjs/index.ts new file mode 100644 index 0000000..8b751d2 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/index.ts @@ -0,0 +1,77 @@ +import type { EmitterRegistry } from "../../types"; +import { emitEnum } from "./enum.emitter"; +import { emitException } from "./exception.emitter"; +import { emitController } from "./controller.emitter"; +import { emitService } from "./service.emitter"; +import { emitRepository } from "./repository.emitter"; +import { emitDto } from "./dto.emitter"; +import { emitModel } from "./model.emitter"; +import { emitTable } from "./table.emitter"; +import { emitView } from "./view.emitter"; +import { emitCache } from "./cache.emitter"; +import { emitMessageQueue } from "./message-queue.emitter"; +import { emitWorker } from "./worker.emitter"; +import { emitApiGateway } from "./api-gateway.emitter"; +import { emitEventHandler } from "./event-handler.emitter"; +import { emitOrchestrator } from "./orchestrator.emitter"; +import { emitExternalService } from "./external-service.emitter"; +import { emitMiddleware } from "./middleware.emitter"; + +/* ──────────────────────────────────────────────────────────────────────── + * emitters/nestjs/index.ts — NestJS EMITTER_REGISTRY. + * + * nodeKind -> emitter mapping. `supported: true` -> full generation; `false` -> + * stub (counted in summary.skippedKinds). Kinds absent from REGISTRY are also + * written to skippedKinds by codegen.service (not silently dropped). + * + * ARCHITECTURE-AWARE INTEGRATION STATUS: + * - Backend chain (Controller/Service/Repository/DTO/Model/Table/Enum/ + * Exception) -> supported: true (full generation). + * - Architecture infra types (Cache/MessageQueue/Worker/EventHandler/ + * Orchestrator/ExternalService/Middleware/APIGateway/View) NOW full + * generation -> supported: true (real NestJS code). Module/scaffold wiring + * via ir.ts feature-inference + module.emitter + scaffold.emitter. + * - Module NOT in REGISTRY: synthesized per FEATURE, not per-node + * (orchestrator calls emitFeatureModule). Raw Module node if present used as + * feature SEED (ir.ts), does not emit its own file. + * - FrontendApp/UIComponent NOT in REGISTRY (EXCLUDED_KINDS): outside NestJS backend + * scope -> codegen.service isExcluded counts in skippedKinds without generating files + * (emitStub only runs on direct/test calls). + * - Scaffold (project-wide) does NOT enter REGISTRY; orchestrator calls separately. + * + * Order does not matter (Record); grouped by kind for readability. + * ──────────────────────────────────────────────────────────────────────── */ + +export const EMITTER_REGISTRY: EmitterRegistry = { + // ── Configuration ── + Enum: { kind: "Enum", emit: emitEnum, supported: true }, + + // ── Backend chain (full generation; Module excepted -> feature synthesis) ── + Exception: { kind: "Exception", emit: emitException, supported: true }, + Controller: { kind: "Controller", emit: emitController, supported: true }, + Service: { kind: "Service", emit: emitService, supported: true }, + Repository: { kind: "Repository", emit: emitRepository, supported: true }, + DTO: { kind: "DTO", emit: emitDto, supported: true }, + Model: { kind: "Model", emit: emitModel, supported: true }, + Table: { kind: "Table", emit: emitTable, supported: true }, + // View is a DB view -> SQL migration (CREATE VIEW), at migrations/ root like Table. + View: { kind: "View", emit: emitView, supported: true }, + + // ── Architecture infra (full generation; real NestJS code) ────────────────────── + Cache: { kind: "Cache", emit: emitCache, supported: true }, + MessageQueue: { kind: "MessageQueue", emit: emitMessageQueue, supported: true }, + Worker: { kind: "Worker", emit: emitWorker, supported: true }, + APIGateway: { kind: "APIGateway", emit: emitApiGateway, supported: true }, + EventHandler: { kind: "EventHandler", emit: emitEventHandler, supported: true }, + Orchestrator: { kind: "Orchestrator", emit: emitOrchestrator, supported: true }, + ExternalService: { kind: "ExternalService", emit: emitExternalService, supported: true }, + Middleware: { kind: "Middleware", emit: emitMiddleware, supported: true }, + + // FrontendApp / UIComponent: outside backend scope (EXCLUDED_KINDS) -> NOT in REGISTRY; + // codegen.service isExcluded counts in skippedKinds without generating files. + // `emitStub` MINIMAL STUB only when called directly (in tests). + // EnvironmentVariable NOT in REGISTRY: an env var is NOT a code module, + // it is config. Single representation: scaffold's src/.env.example + src/config/ + // configuration.ts; meaningless `export class XStub {}` files are NOT + // generated. Unregistered -> codegen.service counts in skippedKinds. +}; diff --git a/apps/server/src/codegen/emitters/nestjs/message-queue.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/message-queue.emitter.spec.ts new file mode 100644 index 0000000..e25bb24 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/message-queue.emitter.spec.ts @@ -0,0 +1,225 @@ +import { describe, it, expect } from "vitest"; +import { emitMessageQueue } from "./message-queue.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; +import type { EdgeKind } from "../../../edges/schemas/edge.schema"; + +/* ── Fixture helpers (same shape as service.emitter.spec.ts) ───────── */ +const PROJECT = "00000000-0000-4000-8000-000000000000"; +const TAB = "22222222-2222-4222-8222-222222222222"; + +function node(type: StoredNode["type"], id: string, properties: Record): StoredNode { + return { + id, + type, + projectId: PROJECT, + positionX: 0, + positionY: 0, + homeTabId: TAB, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function edge(id: string, kind: EdgeKind, sourceNodeId: string, targetNodeId: string): StoredEdge { + return { + id, + projectId: PROJECT, + sourceNodeId, + targetNodeId, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: false }, + }; +} + +function ctxFrom(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { + return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; +} + +/* ── ID'ler ─────────────────────────────────────────────────────────────── */ +const MQ = "30000000-0000-4000-8000-000000000001"; +const DTO_JOB = "30000000-0000-4000-8000-000000000002"; +const SVC = "30000000-0000-4000-8000-000000000003"; + +/* ── Node fixtures ──────────────────────────────────────────────────── */ +const imageJobDto = node("DTO", DTO_JOB, { + Name: "ImageJobDto", + Description: "Production job body", + Fields: [{ Name: "prompt", DataType: "string", IsRequired: true, IsArray: false }], +}); + +const imageQueue = node("MessageQueue", MQ, { + QueueName: "ImageMessageQueue", + Description: "Image production queue", + Type: "Queue", + Provider: "Generic", + MessageFormat: "ImageJobDto", + DeliveryGuarantee: "at-least-once", + MaxRetries: 3, + DeadLetterQueue: "image-dlq", +}); + +// Kuyrugu kullanan bir Service -> feature inference kuyrugu "image" feature'ina ceker. +const imageService = node("Service", SVC, { + ServiceName: "ImageService", + Description: "Image business logic", + IsTransactionScoped: false, + Dependencies: [], + Methods: [], +}); + +describe("emitMessageQueue", () => { + it("tam producer — snapshot (BullMQ Queue DI, queue sabiti, payload DTO, surgical publish)", () => { + const ctx = ctxFrom([imageQueue, imageJobDto, imageService], [edge("e-mq", "CALLS", SVC, MQ)]); + const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { InjectQueue } from "@nestjs/bullmq"; + import { Injectable } from "@nestjs/common"; + import type { Queue } from "bullmq"; + import type { ImageJobDto } from "../common/dto/image-job.dto"; + + /** "MessageQueue" queue name — single source of truth shared between BullModule.registerQueue and @InjectQueue. */ + export const IMAGE_MESSAGE_QUEUE = "ImageMessageQueue"; + + /** Image production queue */ + @Injectable() + export class ImageMessageQueue { + constructor( + @InjectQueue(IMAGE_MESSAGE_QUEUE) private readonly queue: Queue, + ) {} + + /** Adds a message/job to the queue. */ + async publish(payload: ImageJobDto): Promise { + // @solarch:surgical id=30000000-0000-4000-8000-000000000001#publish + // Adds a job to the queue (BullMQ producer). ImageMessageQueue + // delivery: at-least-once + // maxRetries: 3 + // dead-letter: image-dlq + // deps: this.queue + // @solarch:filled by=codegen + await this.queue.add("publish", payload); + } + } + ", + "language": "typescript", + "path": "image/image.queue.ts", + "surgicalMarkers": 0, + } + `); + }); + + it("BullMQ producer iskeleti: @Injectable, @InjectQueue, Queue tip importu", () => { + const ctx = ctxFrom([imageQueue, imageJobDto, imageService], [edge("e-mq", "CALLS", SVC, MQ)]); + const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); + expect(file.content).toContain('import { Injectable } from "@nestjs/common";'); + expect(file.content).toContain('import { InjectQueue } from "@nestjs/bullmq";'); + expect(file.content).toContain('import type { Queue } from "bullmq";'); + expect(file.content).toContain("@Injectable()"); + expect(file.content).toContain("export class ImageMessageQueue {"); + }); + + it("queue adi sabiti @InjectQueue + (Wire) registerQueue icin TEK SOURCE", () => { + const ctx = ctxFrom([imageQueue, imageJobDto, imageService], [edge("e-mq", "CALLS", SVC, MQ)]); + const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); + expect(file.content).toContain('export const IMAGE_MESSAGE_QUEUE = "ImageMessageQueue";'); + expect(file.content).toContain("@InjectQueue(IMAGE_MESSAGE_QUEUE) private readonly queue: Queue,"); + }); + + it("publish GERCEK govde tasir (queue.add) + marker + codegen-dolu damgasi (fill sayimi tutarli)", () => { + const ctx = ctxFrom([imageQueue, imageJobDto, imageService], [edge("e-mq", "CALLS", SVC, MQ)]); + const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); + expect(file.content).toContain("async publish(payload: ImageJobDto): Promise {"); + expect(file.content).toContain('await this.queue.add("publish", payload);'); + expect(file.content).toContain(`// @solarch:surgical id=${MQ}#publish`); + expect(file.content).toContain("// deps: this.queue"); + // Govde codegen tarafindan tam uretildi → @solarch:filled by=codegen damgasi. + expect(file.content).toContain("// @solarch:filled by=codegen"); + // "Doldurulacak" SAYILMAZ (codegen-dolu) → 0; aksi halde 71 gosterilir 69 doldurulur. + expect(file.surgicalMarkers).toBe(0); + }); + + it("MessageFormat -> DTO import edilir (payload tipi DTO sinifi)", () => { + const ctx = ctxFrom([imageQueue, imageJobDto, imageService], [edge("e-mq", "CALLS", SVC, MQ)]); + const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); + expect(file.content).toMatch(/import type \{ ImageJobDto \} from ".*image-job\.dto"/); + }); + + it("dosya yolu feature/.queue.ts (rol son-eki TEKRARSIZ)", () => { + const ctx = ctxFrom([imageQueue, imageJobDto, imageService], [edge("e-mq", "CALLS", SVC, MQ)]); + const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); + // baseNameOf: "ImageMessageQueue" -> "Image" -> image.queue.ts. + expect(file.path).toBe("image/image.queue.ts"); + }); + + it("content ends with single newline", () => { + const ctx = ctxFrom([imageQueue, imageJobDto, imageService], [edge("e-mq", "CALLS", SVC, MQ)]); + const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: two independent graph builds -> byte-identical", () => { + const nodes = [imageQueue, imageJobDto, imageService]; + const edges = [edge("e-mq", "CALLS", SVC, MQ)]; + const a = emitMessageQueue(buildCodeGraph(nodes, edges).byId(MQ)!, ctxFrom(nodes, edges))[0].content; + const b = emitMessageQueue(buildCodeGraph(nodes, edges).byId(MQ)!, ctxFrom(nodes, edges))[0].content; + expect(a).toBe(b); + }); + + it("'Queue' son-ekli ad da calisir (ImageJobsQueue -> ImageJobs base)", () => { + const q = node("MessageQueue", MQ, { + QueueName: "ImageJobsQueue", + Description: "Jobs", + Type: "Queue", + Provider: "Generic", + MessageFormat: "ImageJobDto", + }); + const ctx = ctxFrom([q, imageJobDto], []); + const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); + // baseNameOf: "ImageJobsQueue" -> "ImageJobs" -> image-jobs.queue.ts. + expect(file.path).toContain("image-jobs.queue.ts"); + expect(file.content).toContain('export const IMAGE_JOBS_QUEUE = "ImageJobsQueue";'); + expect(file.content).toContain("export class ImageJobsQueue {"); + }); + + /* ── EDGE-CASE: kayip/bos MessageFormat — throw etmez, unknown'a duser ─── */ + it("edge-case: cozulemeyen MessageFormat -> payload unknown, import yok, throw yok", () => { + const q = node("MessageQueue", MQ, { + QueueName: "OrphanQueue", + Description: "Yetim kuyruk", + Type: "Topic", + Provider: "Generic", + MessageFormat: "GhostDto", + }); + const ctx = ctxFrom([q], []); + let file: { content: string; path: string; surgicalMarkers: number } | undefined; + expect(() => { + file = emitMessageQueue(ctx.graph.byId(MQ)!, ctx)[0]; + }).not.toThrow(); + expect(file!.content).toContain("async publish(payload: unknown): Promise {"); + expect(file!.content).not.toContain("import type { GhostDto }"); + // queue.add govdesi yine GERCEK + tek surgical marker. + expect(file!.content).toContain('await this.queue.add("publish", payload);'); + expect(file!.surgicalMarkers).toBe(0); // codegen-dolu (publish tam uretildi) → doldurulacak sayisi 0 + }); + + it("edge-case: MessageFormat hic yok -> payload unknown", () => { + const q = node("MessageQueue", MQ, { + QueueName: "BareQueue", + Description: "Bare", + Type: "Queue", + Provider: "Generic", + MessageFormat: "", + }); + const ctx = ctxFrom([q], []); + const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); + expect(file.content).toContain("async publish(payload: unknown): Promise {"); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/message-queue.emitter.ts b/apps/server/src/codegen/emitters/nestjs/message-queue.emitter.ts new file mode 100644 index 0000000..6cb6eaf --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/message-queue.emitter.ts @@ -0,0 +1,183 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import { propsOf, type CodeGraph, type CodeNode } from "../../ir"; +import { filePathFor, importPathOf, pascalCase, relativeImportPath } from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers, surgicalMarker } from "../../surgical"; + +/* ──────────────────────────────────────────────────────────────────────── + * message-queue.emitter.ts — MessageQueueNode -> /.queue.ts. + * + * Mirrors enum.emitter.ts (canonical reference) exactly: + * - named `export const emitMessageQueue: NodeEmitter`; no default export. + * - PURE function (node, ctx) -> GeneratedFile[]; no I/O, no throw. + * - Path always via filePathFor(node, ctx.graph) (hardcode FORBIDDEN). + * - imports via ImportCollector (manual "import" FORBIDDEN). + * - DETERMINISTIC: single file, fixed ordering, no timestamp/random. + * - Content ends with single "\n". + * + * OUTPUT: @Injectable() BullMQ PRODUCER (job enqueue side). + * - constructor injects `@InjectQueue(QUEUE_NAME) private readonly queue: Queue` + * (Queue type from "bullmq", @InjectQueue/@nestjs/bullmq). + * - Queue name written as deterministic export const + * (`export const _QUEUE = ""`) — SINGLE SOURCE between + * BullModule.registerQueue({ name }) in module and @InjectQueue. + * - `publish(payload: )` carries REAL body: + * await this.queue.add(, payload); + * Surgical marker left above — Surgical AI extends retry/opts/idempotency + * at marked point (body still COMPILES + runs). + * + * MessageFormat -> DTO node Name (schema description). When resolved payload type + * is that DTO class (imported); else falls back to `unknown` (never throws). + * + * When Type=Topic NOTE: BullMQ is single-queue model; Topic semantics embedded in + * queue name (job name = "publish"); channel/exchange difference left to Surgical AI. + * ──────────────────────────────────────────────────────────────────────── */ + +type MessageQueueProps = { + QueueName: string; + Description?: string; + Type?: "Queue" | "Topic"; + Provider?: string; + MessageFormat?: string; + DeliveryGuarantee?: string; + MaxRetries?: number; + DeadLetterQueue?: string; + RetentionSeconds?: number; +}; + +export const emitMessageQueue: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = readProps(node); + const graph = ctx.graph; + const className = pascalCase(node.name); + const filePath = filePathFor(node, graph); + + // Queue name = QueueName (else resolved node name). @InjectQueue + module + // registerQueue share this VALUE -> single source const. + const queueName = (props.QueueName && props.QueueName.length > 0 ? props.QueueName : node.name) || "queue"; + const queueConst = queueNameConst(node); + + // Payload type: MessageFormat -> DTO node. When resolved class + import; else unknown. + const payloadType = resolvePayloadType(props.MessageFormat, graph, filePath); + + const imports = new ImportCollector(); + imports.add("Injectable", "@nestjs/common"); + imports.add("InjectQueue", "@nestjs/bullmq"); + imports.addType("Queue", "bullmq"); + if (payloadType.importFrom) { + imports.addType(payloadType.className, importPathOf(relativeImportPath(filePath, payloadType.importFrom))); + } + + const lines: string[] = []; + + // Queue name const — SINGLE SOURCE between module registerQueue and @InjectQueue. + lines.push(`/** "${node.kindOf()}" queue name — single source of truth shared between BullModule.registerQueue and @InjectQueue. */`); + lines.push(`export const ${queueConst} = ${JSON.stringify(queueName)};`); + lines.push(""); + + if (props.Description) lines.push(`/** ${props.Description} */`); + lines.push("@Injectable()"); + lines.push(`export class ${className} {`); + + // DI: BullMQ Queue (Wire phase binds BullModule.registerQueue({ name: })). + lines.push(" constructor("); + lines.push(` @InjectQueue(${queueConst}) private readonly queue: Queue,`); + lines.push(" ) {}"); + lines.push(""); + + // publish(payload) — REAL body + surgical marker (retry/opts extension point). + lines.push(...renderPublishMethod(node, className, payloadType.className, props)); + + lines.push("}"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: filePath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/** Queue name const — SCREAMING_SNAKE like `IMAGE_MESSAGE_QUEUE_QUEUE`. + * pascal(name) split into words + upper-snake + "_QUEUE" suffix. + * EXPORT: module.emitter (Wire phase) wants this SYMBOL NAME for BullModule.registerQueue({ name: }); + * const VALUE stays in .queue.ts (single source). */ +export function queueNameConst(node: CodeNode): string { + const screaming = pascalCase(node.name) + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .toUpperCase(); + const base = screaming.length > 0 ? screaming : "QUEUE"; + return base.endsWith("_QUEUE") || base === "QUEUE" ? base : `${base}_QUEUE`; +} + +/** publish(payload) method: real `queue.add` call + surgical marker above. */ +function renderPublishMethod( + node: CodeNode, + className: string, + payloadType: string, + props: MessageQueueProps, +): string[] { + const indent = " "; + // Job name deterministic "publish" (BullMQ single queue; job name fixed). + const jobName = '"publish"'; + + // Surgical marker: behavior extension point (retry/backoff/idempotency). + const deliveryNote = props.DeliveryGuarantee ? `delivery: ${props.DeliveryGuarantee}` : undefined; + const retryNote = typeof props.MaxRetries === "number" ? `maxRetries: ${props.MaxRetries}` : undefined; + const dlqNote = props.DeadLetterQueue ? `dead-letter: ${props.DeadLetterQueue}` : undefined; + const description = [ + `Adds a job to the queue (BullMQ producer). ${node.name}`, + deliveryNote, + retryNote, + dlqNote, + ] + .filter((s): s is string => Boolean(s)) + .join("\n"); + + const marker = surgicalMarker({ + nodeId: node.id, + member: "publish", + description, + deps: ["this.queue"], + }); + + const lines: string[] = []; + lines.push(`${indent}/** Adds a message/job to the queue. */`); + lines.push(`${indent}async publish(payload: ${payloadType}): Promise {`); + for (const ml of marker.split("\n")) lines.push(`${indent}${indent}${ml}`); + // Body DETERMINISTICALLY FULLY generated (BullMQ producer = this.queue.add). Marker kept as + // extension point but this region NOT "to fill" -> codegen-filled stamp. Else without + // NOT_IMPLEMENTED it would count as "filled" and fill silently skips -> count mismatch + // (total shown, fewer processed). + lines.push(`${indent}${indent}// @solarch:filled by=codegen`); + lines.push(`${indent}${indent}await this.queue.add(${jobName}, payload);`); + lines.push(`${indent}}`); + return lines; +} + +/** MessageFormat -> DTO node Name. When resolved payload type is that DTO class (imported); + * else `unknown` (never throws). */ +function resolvePayloadType( + messageFormat: string | undefined, + graph: CodeGraph, + fromFile: string, +): { className: string; importFrom: string | null } { + const fmt = (messageFormat ?? "").trim(); + if (fmt.length === 0) return { className: "unknown", importFrom: null }; + const dto = graph.resolveRef("DTO", fmt); + if (dto) { + return { className: pascalCase(dto.name), importFrom: filePathFor(dto, graph) }; + } + // Unresolved free name -> unknown (tolerance; no import). + return { className: "unknown", importFrom: null }; +} + +/** Read node.properties as MessageQueueProps safely (Zod-validated DB; + * type narrowing only — no runtime transform). */ +function readProps(node: CodeNode): MessageQueueProps { + return node.properties as MessageQueueProps; +} diff --git a/apps/server/src/codegen/emitters/nestjs/middleware.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/middleware.emitter.spec.ts new file mode 100644 index 0000000..0383403 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/middleware.emitter.spec.ts @@ -0,0 +1,232 @@ +import { describe, it, expect } from "vitest"; +import { emitMiddleware } from "./middleware.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +const PROJECT = "00000000-0000-4000-8000-000000000000"; +const TAB = "22222222-2222-4222-8222-222222222222"; + +function node(type: StoredNode["type"], id: string, properties: Record): StoredNode { + return { + id, + type, + projectId: PROJECT, + positionX: 0, + positionY: 0, + homeTabId: TAB, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function edge(kind: StoredEdge["kind"], source: string, target: string, id: string): StoredEdge { + return { + id, + projectId: PROJECT, + sourceNodeId: source, + targetNodeId: target, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: false }, + }; +} + +function ctxFor(nodes: StoredNode[], edges: StoredEdge[] = []): EmitterContext { + return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; +} + +const MW_ID = "a1111111-1111-4111-8111-111111111111"; +const CTRL_ID = "c2222222-2222-4222-8222-222222222222"; +const SVC_ID = "53333333-3333-4333-8333-333333333333"; + +const AUTH_MIDDLEWARE = node("Middleware", MW_ID, { + MiddlewareName: "AuthMiddleware", + Description: "Validates JWT on incoming requests", + AppliesTo: "SpecificRoutes", + ExecutionOrder: 0, + MiddlewareType: "Auth", + Config: [ + { Key: "tokenHeader", Value: "authorization" }, + { Key: "secretEnv", Value: "JWT_SECRET" }, + ], +}); + +const AUTH_CONTROLLER = node("Controller", CTRL_ID, { + ControllerName: "AuthController", + Description: "Authentication HTTP surface", + BaseRoute: "auth", + Endpoints: [], +}); + +const AUTH_SERVICE = node("Service", SVC_ID, { + ServiceName: "AuthService", + Description: "Identity business logic", + IsTransactionScoped: false, + Methods: [], + Dependencies: [], +}); + +describe("emitMiddleware", () => { + it("ROUTES_TO ile feature'a dusen tam middleware — snapshot", () => { + // Middleware -ROUTES_TO-> AuthController -CALLS-> AuthService => feature "auth". + const ctx = ctxFor( + [AUTH_MIDDLEWARE, AUTH_CONTROLLER, AUTH_SERVICE], + [ + edge("ROUTES_TO", MW_ID, CTRL_ID, "e1111111-1111-4111-8111-111111111111"), + edge("CALLS", CTRL_ID, SVC_ID, "e2222222-2222-4222-8222-222222222222"), + ], + ); + const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { Injectable, type NestMiddleware } from "@nestjs/common"; + import type { NextFunction, Request, Response } from "express"; + + /** Validates JWT on incoming requests */ + @Injectable() + export class AuthMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction): void { + // @solarch:surgical id=a1111111-1111-4111-8111-111111111111#use + // Auth middleware: implement the use() body. + // Scope: applied only to specific routes (SpecificRoutes). + // Execution order (ExecutionOrder): 0. + // Wiring hint: for AuthController use configure(consumer).apply(AuthMiddleware).forRoutes(...) (the module phase wires this). + // Config keys: tokenHeader, secretEnv. + throw new Error("NOT_IMPLEMENTED: AuthMiddleware.use"); + } + } + ", + "language": "typescript", + "path": "auth/auth.middleware.ts", + "surgicalMarkers": 1, + } + `); + }); + + it("@Injectable() implements NestMiddleware sinifi + use(req,res,next) imzasi", () => { + const ctx = ctxFor([AUTH_MIDDLEWARE]); + const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); + expect(file.content).toContain("@Injectable()"); + expect(file.content).toContain("export class AuthMiddleware implements NestMiddleware {"); + expect(file.content).toContain("use(req: Request, res: Response, next: NextFunction): void {"); + }); + + it("NestMiddleware + express tipleri import edilir", () => { + const ctx = ctxFor([AUTH_MIDDLEWARE]); + const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); + expect(file.content).toContain('import { Injectable, type NestMiddleware } from "@nestjs/common";'); + expect(file.content).toContain('import type { NextFunction, Request, Response } from "express";'); + }); + + it("use() govdesinde surgical marker + NOT_IMPLEMENTED var", () => { + const ctx = ctxFor([AUTH_MIDDLEWARE]); + const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); + expect(file.surgicalMarkers).toBe(1); + expect(file.content).toContain("// @solarch:surgical id=a1111111-1111-4111-8111-111111111111#use"); + expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: AuthMiddleware.use");'); + }); + + it("feature yoksa (cross-cutting / baglantisiz) common/ altina iner", () => { + // Hic edge yok -> referrerFeatures bos -> pickFeature null -> "common". + const ctx = ctxFor([AUTH_MIDDLEWARE]); + const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); + expect(file.path).toBe("common/auth.middleware.ts"); + }); + + it("filePathFor kullanir: feature'a dustugunde /.middleware.ts", () => { + const ctx = ctxFor( + [AUTH_MIDDLEWARE, AUTH_CONTROLLER, AUTH_SERVICE], + [ + edge("ROUTES_TO", MW_ID, CTRL_ID, "e1111111-1111-4111-8111-111111111111"), + edge("CALLS", CTRL_ID, SVC_ID, "e2222222-2222-4222-8222-222222222222"), + ], + ); + const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); + expect(file.path).toBe("auth/auth.middleware.ts"); + // Dosya kok adi baseNameOf(AuthMiddleware) = "Auth" -> kebab "auth". + expect(file.path).not.toContain("auth-middleware.middleware"); + }); + + it("ROUTES_TO Controller adi uygulanis ipucu olarak markera girer", () => { + const ctx = ctxFor( + [AUTH_MIDDLEWARE, AUTH_CONTROLLER, AUTH_SERVICE], + [ + edge("ROUTES_TO", MW_ID, CTRL_ID, "e1111111-1111-4111-8111-111111111111"), + edge("CALLS", CTRL_ID, SVC_ID, "e2222222-2222-4222-8222-222222222222"), + ], + ); + const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); + expect(file.content).toContain( + "// Wiring hint: for AuthController use configure(consumer).apply(AuthMiddleware).forRoutes(...) (the module phase wires this).", + ); + }); + + it("Config: yalniz Key'ler markera girer, gizli Value ASLA gomulmez", () => { + const ctx = ctxFor([AUTH_MIDDLEWARE]); + const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); + expect(file.content).toContain("// Config keys: tokenHeader, secretEnv."); + // Degerler (authorization / JWT_SECRET) icerige SIZMAMALI. + expect(file.content).not.toContain("authorization"); + expect(file.content).not.toContain("JWT_SECRET"); + }); + + it("Global AppliesTo + Config'siz minimal middleware", () => { + const minimal = node("Middleware", MW_ID, { + MiddlewareName: "LoggingMiddleware", + Description: "Logs requests", + AppliesTo: "Global", + ExecutionOrder: 5, + Config: [], + }); + const ctx = ctxFor([minimal]); + const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); + expect(file.content).toContain("export class LoggingMiddleware implements NestMiddleware {"); + expect(file.content).toContain("// Scope: applied to all routes (Global)."); + expect(file.content).toContain("// Execution order (ExecutionOrder): 5."); + // MiddlewareType yoksa tur oneki olmadan "middleware use() ..." satiri. + expect(file.content).toContain("// middleware: implement the use() body."); + // Config bos -> "Config keys" satiri yok. + expect(file.content).not.toContain("Config keys"); + }); + + it("content ends with single newline", () => { + const ctx = ctxFor([AUTH_MIDDLEWARE]); + const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: same graph twice -> byte-identical", () => { + const ctx = ctxFor( + [AUTH_MIDDLEWARE, AUTH_CONTROLLER, AUTH_SERVICE], + [ + edge("ROUTES_TO", MW_ID, CTRL_ID, "e1111111-1111-4111-8111-111111111111"), + edge("CALLS", CTRL_ID, SVC_ID, "e2222222-2222-4222-8222-222222222222"), + ], + ); + const a = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx)[0].content; + const b = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx)[0].content; + expect(a).toBe(b); + }); + + it("kind adiyla bitmeyen / bos'a dusmeyen ad korunur (rol eki adin TAMAMIYSA)", () => { + // "Middleware" -> baseNameOf adin tamami eki -> orijinal ad korunur. + const odd = node("Middleware", MW_ID, { + MiddlewareName: "Middleware", + Description: "kenar durum", + AppliesTo: "Global", + ExecutionOrder: 0, + Config: [], + }); + const ctx = ctxFor([odd]); + const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); + expect(file.content).toContain("export class Middleware implements NestMiddleware {"); + expect(file.path).toBe("common/middleware.middleware.ts"); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/middleware.emitter.ts b/apps/server/src/codegen/emitters/nestjs/middleware.emitter.ts new file mode 100644 index 0000000..781c1e5 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/middleware.emitter.ts @@ -0,0 +1,126 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import type { CodeGraph, CodeNode } from "../../ir"; +import { filePathFor, pascalCase } from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers, notImplemented, surgicalMarker } from "../../surgical"; +import type { MiddlewareNode } from "../../../nodes/schemas"; + +/* ──────────────────────────────────────────────────────────────────────── + * middleware.emitter.ts — MiddlewareNode -> /.middleware.ts + * (common/.middleware.ts when no feature). + * + * Emits @Injectable() implements NestMiddleware NestJS middleware: + * - Class name = pascalCase(MiddlewareName) (e.g. "AuthMiddleware"). + * - Single method: use(req: Request, res: Response, next: NextFunction): void. + * Body = surgicalMarker (Description + MiddlewareType + AppliesTo + + * ExecutionOrder + Config hints) + notImplemented(). Surgical AI fills + * the marked point. + * - Controllers it ROUTES_TO appear as "application hint" in marker description; + * actual `configure(consumer).apply(X).forRoutes(...)` wiring happens in Wire/ + * module phase on that Controller's feature module (this emitter ONLY + * produces the middleware class). + * + * PURE + DETERMINISTIC: collections in given/sorted order, missing refs tolerated + * (NO THROW), imports via ImportCollector, content ends with single "\n". + * + * NOTE: Middleware NOT in ir.ts PropsByKind (was one of 12 stub families) -> + * propsOf<"Middleware"> CANNOT be used. Use local middlewareProps() helper for + * typed access (DB already Zod-validated; type narrowing only, no runtime transform). + * ──────────────────────────────────────────────────────────────────────── */ + +/** Typed Middleware properties access (local because outside PropsByKind). */ +type MiddlewareProps = MiddlewareNode["properties"]; +function middlewareProps(node: CodeNode): MiddlewareProps { + return node.properties as MiddlewareProps; +} + +export const emitMiddleware: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = middlewareProps(node); + const className = pascalCase(node.name) || pascalCase(node.kindOf()); + const filePath = filePathFor(node, ctx.graph); + const graph = ctx.graph; + + const imports = new ImportCollector(); + imports.add("Injectable", "@nestjs/common"); + imports.addType("NestMiddleware", "@nestjs/common"); + // Express types — compatible with NestJS default HTTP adapter (Express). + imports.addType("NextFunction", "express"); + imports.addType("Request", "express"); + imports.addType("Response", "express"); + + // ── Surgical body description ────────────────────────────────────────────── + const desc = describeMiddleware(node, props, graph); + const marker = surgicalMarker({ + nodeId: node.id, + member: "use", + description: desc, + }); + + const indent = " "; + const lines: string[] = []; + if (props.Description) lines.push(`/** ${props.Description} */`); + lines.push("@Injectable()"); + lines.push(`export class ${className} implements NestMiddleware {`); + lines.push(`${indent}use(req: Request, res: Response, next: NextFunction): void {`); + for (const ml of marker.split("\n")) lines.push(`${indent}${indent}${ml}`); + lines.push(`${indent}${indent}${notImplemented(className, "use")}`); + lines.push(`${indent}}`); + lines.push("}"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: filePath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/** Build DETERMINISTIC multi-line work description for surgical marker: + * - user Description (when present), + * - MiddlewareType + AppliesTo + ExecutionOrder context, + * - Controller names it ROUTES_TO (application hint), + * - Config keys (in given order; secret values NEVER written — Keys only). */ +function describeMiddleware( + node: CodeNode, + props: MiddlewareProps, + graph: CodeGraph, +): string { + const parts: string[] = []; + + const typePart = props.MiddlewareType ? `${props.MiddlewareType} ` : ""; + parts.push(`${typePart}middleware: implement the use() body.`); + + if (props.AppliesTo === "Global") { + parts.push("Scope: applied to all routes (Global)."); + } else { + parts.push("Scope: applied only to specific routes (SpecificRoutes)."); + } + parts.push(`Execution order (ExecutionOrder): ${props.ExecutionOrder}.`); + + // ROUTES_TO -> Controller (middleware routes to one or more controllers). + // CodeGraph keeps edges sorted by kind,source.name,target.name,id -> + // outEdges arrive in deterministic order. + const routedControllers: string[] = []; + for (const e of graph.outEdges(node.id, "ROUTES_TO")) { + const tgt = graph.byId(e.targetNodeId); + if (tgt && tgt.kindOf() === "Controller") routedControllers.push(tgt.name); + } + if (routedControllers.length > 0) { + parts.push( + `Wiring hint: for ${routedControllers.join(", ")} use ` + + `configure(consumer).apply(${pascalCase(node.name)}).forRoutes(...) (the module phase wires this).`, + ); + } + + // Config keys (Keys only; secret values NEVER embedded). + const configKeys = (props.Config ?? []).map((c) => c.Key).filter((k) => k.length > 0); + if (configKeys.length > 0) { + parts.push(`Config keys: ${configKeys.join(", ")}.`); + } + + return parts.join("\n"); +} diff --git a/apps/server/src/codegen/emitters/nestjs/migration-runner.emitter.ts b/apps/server/src/codegen/emitters/nestjs/migration-runner.emitter.ts new file mode 100644 index 0000000..ba2fd56 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/migration-runner.emitter.ts @@ -0,0 +1,157 @@ +import type { GeneratedFile } from "../../types"; +import { pascalCase } from "../../naming"; +import { countSurgicalMarkers } from "../../surgical"; + +/* ──────────────────────────────────────────────────────────────────────── + * migration-runner.emitter.ts — produce RUNNABLE TypeORM TS migration classes + * from raw SQL migrations (H5). + * + * Problem: table.emitter / view.emitter produce `migrations/NNN_create_.sql`; + * these are READABLE but not runnable by TypeORM CLI. This emitter takes SQL files + * collected at assembly and for each produces + * `src/migrations/NNN-Create.ts` (MigrationInterface): + * - up(queryRunner) -> runs SQL statements in order + * - down(queryRunner) -> DROP table/view (reverse) + * data-source.ts `dist/migrations/*.js` glob picks these up -> `npm run db:migrate` + * can apply schema. synchronize:false preserved. + * + * PURE + DETERMINISTIC: input is SQL files only (given sorted); no timestamp/ + * random. SQL raw text embedded in template literal with SAFE escaping. + * Scaffold/orchestrator helper; NOT node-bound (no nodeId). + * ──────────────────────────────────────────────────────────────────────── */ + +/** One collected SQL migration file (from assembly; path "migrations/...sql"). */ +export interface SqlMigrationFile { + /** e.g. "migrations/001_create_users.sql". */ + path: string; + /** Raw SQL content (table/view emitter output). */ + content: string; +} + +/** Produce TypeORM TS migration classes from sorted SQL migration files. + * Files must be given in SAME order (by NNN). TypeORM parses LAST 13 CHARACTERS + * of class name as JS-millisecond timestamp (MigrationExecutor: + * parseInt(name.substr(-13))) and sorts migrations by it; plain "001" + * suffix yields NaN and CLI throws. So we derive DETERMINISTIC 13-digit + * timestamp from NNN (BASE_TS + seq) and append to class name END -> + * substr(-13) returns exactly those 13 digits, order increases with NNN, no timestamp/random. */ +export function emitMigrationRunners(sqlFiles: SqlMigrationFile[]): GeneratedFile[] { + const out: GeneratedFile[] = []; + for (const sql of sqlFiles) { + const seq = seqOf(sql.path); + const tableName = tableNameOf(sql.path); + if (seq === null || tableName.length === 0) continue; + + const isView = looksLikeView(sql.content); + const ts = syntheticTimestamp(seq); + // Class name ends with -> TypeORM substr(-13) == ts (pure 13 digits). + const className = `Create${pascalCase(tableName)}${ts}`; + const statements = splitSqlStatements(sql.content); + out.push(buildMigrationFile(seq, ts, tableName, className, statements, isView)); + } + return out; +} + +/** Produce TypeORM-compatible DETERMINISTIC 13-digit timestamp from NNN sequence. + * TypeORM only parseInts last 13 digits and sorts by timestamp; real time not + * needed, only monotonically increasing 13-digit number. Fixed BASE_TS + * (2023-11-14) + seq -> timestamp increases with seq; same graph -> byte-identical. */ +function syntheticTimestamp(seq: string): string { + const BASE_TS = 1_700_000_000_000; // fixed 13-digit base (UTC ~2023-11-14) + return String(BASE_TS + Number(seq)); +} + +/* ── Produce one migration file ─────────────────────────────────────── */ +function buildMigrationFile( + seq: string, + ts: string, + tableName: string, + className: string, + statements: string[], + isView: boolean, +): GeneratedFile { + const upLines = statements.map((s) => ` await queryRunner.query(${tsTemplate(s)});`); + const dropKw = isView ? "DROP VIEW IF EXISTS" : "DROP TABLE IF EXISTS"; + const downStmt = `${dropKw} ${quoteIdent(tableName)} CASCADE`; + + const body = `import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Solarch-generated TypeORM migration (${tableName}). + * up(): applies the schema; down(): reverts it. With synchronize:false the schema + * changes ONLY through migrations. Raw SQL reference: migrations/${seq}_create_${tableName}.sql. + */ +export class ${className} implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { +${upLines.join("\n")} + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(${tsTemplate(downStmt)}); + } +} +`; + + return { + // TypeORM convention: -.ts. Glob (dist/migrations/*.js) finds these; + // filename ts also aligns file order with NNN. + path: `src/migrations/${ts}-Create${pascalCase(tableName)}.ts`, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; +} + +/* ── SQL parsing (deterministic; not comment-aware but carries comments + * with statements) ─────────────────────────────────────────── */ + +/** Split raw SQL into ";" terminated statements. Comment lines (-- ...) attach + * to next statement; top-level FK ALTER and CREATE INDEX are separate statements. + * Single line ending normalized. */ +function splitSqlStatements(sql: string): string[] { + const statements: string[] = []; + let buf = ""; + for (const rawLine of sql.split("\n")) { + buf += (buf.length > 0 ? "\n" : "") + rawLine; + // Statement ends with ";" (trailing whitespace ignored). + if (/;\s*$/.test(rawLine)) { + const stmt = buf.trim(); + if (stmt.length > 0) statements.push(stmt.replace(/;\s*$/, "")); + buf = ""; + } + } + const tail = buf.trim(); + if (tail.length > 0) statements.push(tail.replace(/;\s*$/, "")); + // Drop all-comment chunks (only "-- ...") — not executable. + return statements.filter((s) => s.split("\n").some((l) => l.trim().length > 0 && !l.trim().startsWith("--"))); +} + +/** Is this CREATE VIEW / MATERIALIZED VIEW? (for down() DROP VIEW choice.) */ +function looksLikeView(sql: string): boolean { + return /\bCREATE\s+(MATERIALIZED\s+)?VIEW\b/i.test(sql); +} + +/** "migrations/001_create_users.sql" -> "001". */ +function seqOf(path: string): string | null { + const m = path.match(/\/(\d+)_create_/); + return m ? m[1] : null; +} + +/** "migrations/001_create_users.sql" -> "users" (physical name). */ +function tableNameOf(path: string): string { + const m = path.match(/_create_(.+)\.sql$/); + return m ? m[1] : ""; +} + +/** Embed SQL statement in safe TS template literal (escape backtick + ${} + * + backslash). Deterministic. */ +function tsTemplate(sql: string): string { + const escaped = sql.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); + // Template literal for readable multi-line SQL. + return `\`${escaped}\``; +} + +/** Postgres identifier quoting (same as table.emitter). */ +function quoteIdent(ident: string): string { + return `"${ident.replace(/"/g, '""')}"`; +} diff --git a/apps/server/src/codegen/emitters/nestjs/model.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/model.emitter.spec.ts new file mode 100644 index 0000000..8167e0b --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/model.emitter.spec.ts @@ -0,0 +1,348 @@ +import { describe, it, expect } from "vitest"; +import { emitModel } from "./model.emitter"; +import { emitTable } from "./table.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +function modelNode(properties: Record, id: string): StoredNode { + return { + id, + type: "Model", + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function tableNode(properties: Record, id: string): StoredNode { + return { + id, + type: "Table", + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function ctxFor(nodes: StoredNode[], edges: StoredEdge[] = []): { ctx: EmitterContext } { + const graph = buildCodeGraph(nodes, edges); + return { ctx: { graph, target: "nestjs" } }; +} + +const USER_ID = "aaaaaaaa-1111-4111-8111-111111111111"; +const POST_ID = "bbbbbbbb-2222-4222-8222-222222222222"; +const TABLE_ID = "cccccccc-3333-4333-8333-333333333333"; + +const USER_MODEL = { + ClassName: "User", + Description: "Uygulama kullanicisi", + TableRef: "users", + Properties: [ + { Name: "id", Type: "uuid" }, + { Name: "email", Type: "string" }, + { Name: "age", Type: "int", IsNullable: true }, + { Name: "isActive", Type: "boolean" }, + { + Name: "posts", + Type: "Post", + IsCollection: true, + RelationType: "OneToMany", + RelatedModelRef: "Post", + }, + ], + Methods: [ + { + MethodName: "fullName", + Visibility: "public", + Parameters: [], + ReturnType: "string", + IsAsync: false, + IsStatic: false, + }, + ], +}; + +const POST_MODEL = { + ClassName: "Post", + Description: "User gonderisi", + Properties: [ + { Name: "id", Type: "uuid" }, + { Name: "title", Type: "string" }, + ], + Methods: [], +}; + +describe("emitModel", () => { + it("tam model (PK + kolonlar + iliski + method) — snapshot", () => { + const user = modelNode(USER_MODEL, USER_ID); + const post = modelNode(POST_MODEL, POST_ID); + const table = tableNode({ TableName: "users", Columns: [] }, TABLE_ID); + const { ctx } = ctxFor([user, post, table]); + const [file] = emitModel(ctx.graph.byId(USER_ID)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; + + /** Uygulama kullanicisi */ + @Entity("users") + export class User { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "varchar" }) + email!: string; + + @Column({ type: "int", nullable: true }) + age?: number; + + @Column({ type: "boolean" }) + isActive!: boolean; + + // TODO: relation "posts" (OneToMany -> Post) — inverse side required (no reciprocal @ManyToOne found); add manually + + fullName(): string { + // @solarch:surgical id=aaaaaaaa-1111-4111-8111-111111111111#fullName + throw new Error("NOT_IMPLEMENTED: User.fullName"); + } + } + ", + "language": "typescript", + "path": "users/entities/user.entity.ts", + "surgicalMarkers": 1, + } + `); + }); + + it("@Entity adi bagli Table node'unun fiziksel adindan gelir (tekrar cogullanmaz)", () => { + const model = modelNode( + { ClassName: "Category", Description: "kategori", TableRef: "categories", Properties: [{ Name: "id", Type: "uuid" }] }, + USER_ID, + ); + const table = tableNode({ TableName: "categories", Columns: [] }, TABLE_ID); + const { ctx } = ctxFor([model, table]); + const [file] = emitModel(ctx.graph.byId(USER_ID)!, ctx); + expect(file.content).toContain('@Entity("categories")'); + }); + + it("@Entity adi, tekil/PascalCase TableName icin table.emitter ile AYNI (ayrismaz)", () => { + // Tekil/PascalCase TableName: eski hata burada ortaya cikardi (entity 'user', + // migration 'users'). Artik ikisi de tableSqlName -> 'user' (birebir ayni). + const table = tableNode( + { + TableName: "User", + Description: "kullanici", + Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true }], + }, + TABLE_ID, + ); + const model = modelNode( + { ClassName: "User", Description: "kullanici", TableRef: "User", Properties: [{ Name: "id", Type: "uuid" }] }, + USER_ID, + ); + const { ctx } = ctxFor([model, table]); + + const entityFile = emitModel(ctx.graph.byId(USER_ID)!, ctx)[0]; + const tableFile = emitTable(ctx.graph.byId(TABLE_ID)!, ctx)[0]; + + // table.emitter'in CREATE TABLE adi. + const createMatch = tableFile.content.match(/CREATE TABLE "([^"]+)"/); + expect(createMatch).not.toBeNull(); + const physicalName = createMatch![1]; + expect(physicalName).toBe("user"); // cogullanmaz + + // model.emitter'in @Entity argumani ile BIREBIR ayni. + expect(entityFile.content).toContain(`@Entity("${physicalName}")`); + }); + + it("TableRef yoksa @Entity ClassName'den TURETILIR (pluralizeSnake — acik tablo yok)", () => { + const model = modelNode( + { ClassName: "OrderItem", Description: "kalem", Properties: [{ Name: "id", Type: "uuid" }] }, + USER_ID, + ); + const { ctx } = ctxFor([model]); + const [file] = emitModel(ctx.graph.byId(USER_ID)!, ctx); + // Acik TableName yok -> class adindan tablo adi turetilir ("order_items"), + // bu da boyle bir Table eklendiginde dogal/cogul TableName ile tutarlidir. + expect(file.content).toContain('@Entity("order_items")'); + expect(file.path).toBe("order-item/entities/order-item.entity.ts"); + }); + + it("PK 'id' yoksa ilk property primary key olur (uuid degil)", () => { + const model = modelNode( + { ClassName: "Token", Description: "token", Properties: [{ Name: "value", Type: "string" }] }, + USER_ID, + ); + const { ctx } = ctxFor([model]); + const [file] = emitModel(ctx.graph.byId(USER_ID)!, ctx); + expect(file.content).toContain("@PrimaryGeneratedColumn()"); + expect(file.content).toContain("value!: string;"); + }); + + it("EDGE-CASE: kayip iliski referansi -> TODO satiri, throw NONE, import NONE", () => { + const model = modelNode( + { + ClassName: "Comment", + Description: "yorum", + Properties: [ + { Name: "id", Type: "uuid" }, + { + Name: "author", + Type: "User", + RelationType: "ManyToOne", + RelatedModelRef: "Ghost", + }, + ], + }, + USER_ID, + ); + const { ctx } = ctxFor([model]); + expect(() => emitModel(ctx.graph.byId(USER_ID)!, ctx)).not.toThrow(); + const [file] = emitModel(ctx.graph.byId(USER_ID)!, ctx); + expect(file.content).toContain("// TODO: relation \"author\""); + // Kayip ref -> import NONE, dekorator satiri NONE (yalniz TODO yorumu). + expect(file.content).not.toContain("ghost.entity"); + expect(file.content).not.toContain("@ManyToOne"); + }); + + it("TypeORM import sirali; OneToMany inverse-side yoksa TODO'ya duser (decorator/import yok)", () => { + const user = modelNode(USER_MODEL, USER_ID); + const post = modelNode(POST_MODEL, POST_ID); + const { ctx } = ctxFor([user, post]); + const [file] = emitModel(ctx.graph.byId(USER_ID)!, ctx); + // OneToMany'de inverseSide ZORUNLU (TS2554); semada InverseSide yok → iliski TODO, + // OneToMany ve iliski entity'si (Post) import EDILMEZ. + expect(file.content).toMatch(/import \{ Column, Entity, PrimaryGeneratedColumn \} from "typeorm";/); + expect(file.content).not.toContain("@OneToMany"); // decorator emit edilmez (TODO yorumu haric) + expect(file.content).not.toContain("import { Post }"); + expect(file.content).toContain('// TODO: relation "posts" (OneToMany -> Post)'); + }); + + it("OneToMany ters-yon: iliskili Model'de geri-donen @ManyToOne varsa inverse uretilir", () => { + // User.posts (OneToMany -> Post) + Post.author (ManyToOne -> User) → karsilikli. + // Beklenen: @OneToMany(() => Post, (post) => post.author) + Post import + Post[] tipi. + const user = modelNode(USER_MODEL, USER_ID); + const postWithAuthor = modelNode( + { + ClassName: "Post", + Description: "User gonderisi", + Properties: [ + { Name: "id", Type: "uuid" }, + { Name: "title", Type: "string" }, + { Name: "author", Type: "User", RelationType: "ManyToOne", RelatedModelRef: "User" }, + ], + Methods: [], + }, + POST_ID, + ); + const { ctx } = ctxFor([user, postWithAuthor]); + const [file] = emitModel(ctx.graph.byId(USER_ID)!, ctx); + expect(file.content).toContain("@OneToMany(() => Post, (post) => post.author)"); + expect(file.content).toContain("posts!: Post[];"); + expect(file.content).toContain("import { Post }"); + expect(file.content).toContain("OneToMany"); // typeorm import'una eklendi + expect(file.content).not.toContain('// TODO: relation "posts"'); + }); + + it("PascalCase property + .NET Guid → camelCase TS uye + string + @Column uuid", () => { + const id = "aaaa1111-2222-3333-4444-555566667777"; + const model = modelNode( + { + ClassName: "Account", + Description: "x", + Properties: [ + { Name: "Id", Type: "Guid", IsNullable: false, IsPrimaryKey: true }, + { Name: "CustomerId", Type: "Guid", IsNullable: false }, + ], + }, + id, + ); + const { ctx } = ctxFor([model]); + const [file] = emitModel(ctx.graph.byId(id)!, ctx); + expect(file.content).toContain("customerId!: string;"); // PascalCase→camelCase + Guid→string + expect(file.content).not.toContain("CustomerId"); + expect(file.content).not.toContain("Guid"); + expect(file.content).toContain('type: "uuid"'); // @Column Guid→uuid + }); + + it("method govdesi surgical marker + NOT_IMPLEMENTED icerir", () => { + const user = modelNode(USER_MODEL, USER_ID); + const post = modelNode(POST_MODEL, POST_ID); + const { ctx } = ctxFor([user, post]); + const [file] = emitModel(ctx.graph.byId(USER_ID)!, ctx); + expect(file.content).toContain(`// @solarch:surgical id=${USER_ID}#fullName`); + expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: User.fullName");'); + expect(file.surgicalMarkers).toBe(1); + }); + + it("content ends with single newline", () => { + const user = modelNode(USER_MODEL, USER_ID); + const post = modelNode(POST_MODEL, POST_ID); + const { ctx } = ctxFor([user, post]); + const [file] = emitModel(ctx.graph.byId(USER_ID)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: same node twice -> byte-identical", () => { + const user = modelNode(USER_MODEL, USER_ID); + const post = modelNode(POST_MODEL, POST_ID); + const { ctx } = ctxFor([user, post]); + const a = emitModel(ctx.graph.byId(USER_ID)!, ctx)[0].content; + const b = emitModel(ctx.graph.byId(USER_ID)!, ctx)[0].content; + expect(a).toBe(b); + }); + + /* ── ENUM property -> @Column({ type: "varchar" }) + TS tipi generated enum ─── + * #56: native Postgres enum NOT (migration de VARCHAR + CHECK uretir -> tutarli). + * TS alan tipi yine generated enum sinifi (status!: OrderStatus) ve import edilir. */ + it("ENUM property -> @Column({ type: 'varchar' }), TS tipi generated enum + import", () => { + const orderStatus: StoredNode = { + id: "e1e1e1e1-1111-4111-8111-e1e1e1e1e1e1", + type: "Enum", + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties: { + Name: "OrderStatus", + Description: "Order status", + BackingType: "string", + Values: [{ Key: "PENDING", Value: "pending" }, { Key: "PAID", Value: "paid" }], + }, + }; + const order = modelNode( + { + ClassName: "Order", + Description: "Order", + Properties: [ + { Name: "id", Type: "uuid" }, + { Name: "status", Type: "OrderStatus" }, + ], + }, + "0d0d0d0d-1111-4111-8111-0d0d0d0d0d0d", + ); + const { ctx } = ctxFor([order, orderStatus]); + const [file] = emitModel(ctx.graph.byId(order.id)!, ctx); + // @Column VARCHAR (native enum NOT), TS tipi yine generated enum + import. + expect(file.content).toMatch(/@Column\(\{ type: "varchar" \}\)\s*\n\s*status!: OrderStatus;/); + expect(file.content).toContain('import { OrderStatus }'); + expect(file.content).not.toContain('type: "enum"'); + expect(file.content).not.toContain("enum: OrderStatus"); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/model.emitter.ts b/apps/server/src/codegen/emitters/nestjs/model.emitter.ts new file mode 100644 index 0000000..85e4d66 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/model.emitter.ts @@ -0,0 +1,300 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import { propsOf, type CodeNode } from "../../ir"; +import { + filePathFor, + pascalCase, + camelCase, + pluralizeSnake, + tableSqlName, + relativeImportPath, + importPathOf, + tsPropName, + scalarTsType, +} from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers, notImplemented, surgicalMarker } from "../../surgical"; +import { columnOrmType, sqlTypeToTs } from "./sql-type-map"; + +/* ──────────────────────────────────────────────────────────────────────── + * model.emitter.ts — ModelNode -> TypeORM entity. + * + * Sozlesme (enum.emitter.ts kanonik referansiyla birebir): + * - named `export const emitModel: NodeEmitter`; default export NONE. + * - SAF fonksiyon: (node, ctx) -> GeneratedFile[]. I/O yok, throw yok. + * - Yol her zaman filePathFor(node, ctx.graph) ile. + * - import'lar ImportCollector ile (elle "import ..." YASAK). + * - DETERMINISTIC: Properties/Methods verildigi sirada; ref cozumu ctx uzerinden. + * - surgicalMarkers countSurgicalMarkers(content) ile sayilir. + * - Icerik tek "\n" ile biter. + * + * ModelNode -> /entities/.entity.ts. + * @Entity() — TableRef varsa o Table'in fiziksel adi (tableSqlName, + * table.emitter ile TEK SOURCE), yoksa ClassName'in pluralize snake hali. + * PK: "id" adli property -> @PrimaryGeneratedColumn("uuid"); yoksa ilk property. + * RelationType + RelatedModelRef -> @OneToOne/@OneToMany/@ManyToOne/@ManyToMany + * (()=>Related) + import (ctx.resolveRef("Model", RelatedModelRef)). + * Ref cozulemezse iliski satiri atlanir + // TODO yorumu (ASLA throw). + * Methods varsa imza + surgical govde (NOT_IMPLEMENTED). + * ──────────────────────────────────────────────────────────────────────── */ + +type ModelProps = ReturnType>; +type ModelProperty = ModelProps["Properties"][number]; +type ModelMethod = ModelProps["Methods"][number]; + +const RELATION_DECORATOR: Record = { + OneToOne: "OneToOne", + OneToMany: "OneToMany", + ManyToOne: "ManyToOne", + ManyToMany: "ManyToMany", +}; + +export const emitModel: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = propsOf<"Model">(node); + const className = pascalCase(node.name); + const fromPath = filePathFor(node, ctx.graph); + + const imports = new ImportCollector(); + // TypeORM cekirdek dekoratorleri — Entity + Column her zaman gerekli. + imports.add("Column", "typeorm"); + imports.add("Entity", "typeorm"); + + const tableName = resolveTableName(props, node, ctx); + + // PK secimi: "id" adli property oncelik; yoksa ilk property. + const pkProperty = pickPrimaryKey(props.Properties); + + const lines: string[] = []; + if (props.Description) lines.push(`/** ${props.Description} */`); + lines.push(`@Entity(${JSON.stringify(tableName)})`); + lines.push(`export class ${className} {`); + + const memberBlocks: string[] = []; + + for (const p of props.Properties) { + const block = renderProperty(p, p === pkProperty, className, node, ctx, imports, fromPath); + if (block) memberBlocks.push(block); + } + + for (const m of props.Methods ?? []) { + memberBlocks.push(renderMethod(m, className, node)); + } + + // Uyeler arasinda bir bos satir (deterministik). + lines.push(memberBlocks.join("\n\n")); + lines.push("}"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: fromPath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/** @Entity tablosu adi. TEK SOURCE: tableSqlName — table.emitter'in + * `CREATE TABLE` adiyla BIREBIR ayni (aksi halde entity var olmayan bir tabloya + * baglanir). Deterministik. + * - TableRef cozulurse -> tableSqlName(table.name) (table emitter ile ayni). + * - TableRef var ama cozulmezse -> ham ref'i fiziksel ad say (tableSqlName). + * - TableRef yoksa -> ClassName'den tablo adi TURET (pluralizeSnake): acik + * TableName olmadigi icin "User" -> "users", "OrderItem" -> "order_items". + * Bu, boyle bir Table node'u eklendiginde table.emitter'in uretecegi adla + * tutarlidir (kullanici tabloyu dogal/cogul TableName ile adlandirir). */ +function resolveTableName(props: ModelProps, node: CodeNode, ctx: Parameters[1]): string { + if (props.TableRef) { + const table = ctx.graph.resolveRef("Table", props.TableRef); + if (table) return tableSqlName(table.name); + // Ref cozulemedi: yine de niyeti koru (ham ref'i fiziksel ad say). + return tableSqlName(props.TableRef); + } + // Acik tablo yok -> class adindan tablo adi turet (cogullanir). + return pluralizeSnake(node.name); +} + +/** "id" adli property oncelik; yoksa ilk property (varsa). */ +function pickPrimaryKey(properties: ModelProperty[]): ModelProperty | null { + const byId = properties.find((p) => p.Name.toLowerCase() === "id"); + if (byId) return byId; + return properties.length > 0 ? properties[0] : null; +} + +/** Tek bir property -> dekoratorlu alan (iliski ise @OneToMany... + import). */ +function renderProperty( + p: ModelProperty, + isPrimaryKey: boolean, + className: string, + node: CodeNode, + ctx: Parameters[1], + imports: ImportCollector, + fromPath: string, +): string | null { + // Iliski property'si. + if (p.RelationType && p.RelatedModelRef) { + return renderRelation(p, className, node, ctx, imports, fromPath); + } + + const out: string[] = []; + if (isPrimaryKey) { + imports.add("PrimaryGeneratedColumn", "typeorm"); + // id alani uuid varsayimi; aksi halde siradan PK uretici. + const isUuid = p.Name.toLowerCase() === "id"; + out.push(` @PrimaryGeneratedColumn(${isUuid ? '"uuid"' : ""})`); + out.push(` ${fieldDeclaration(p, ctx)}`); + return out.join("\n"); + } + + out.push(` @Column(${columnOptions(p, ctx, imports, fromPath)})`); + out.push(` ${fieldDeclaration(p, ctx)}`); + return out.join("\n"); +} + +/** Iliski dekoratoru + import + alan bildirimi. Ref cozulemezse TODO ile atla. */ +function renderRelation( + p: ModelProperty, + className: string, + node: CodeNode, + ctx: Parameters[1], + imports: ImportCollector, + fromPath: string, +): string | null { + const decorator = RELATION_DECORATOR[p.RelationType as string]; + if (!decorator) return null; + + const related = ctx.graph.resolveRef("Model", p.RelatedModelRef as string); + if (!related) { + // Kayip ref: iliskiyi koy, ama type-safe import yok -> TODO + atla. + return ` // TODO: relation "${tsPropName(p.Name)}" (${p.RelationType} -> ${p.RelatedModelRef}) — reference could not be resolved`; + } + + const relatedClass = pascalCase(related.name); + // TypeORM OneToMany'de inverseSide ZORUNLU (tek-arg @OneToMany TS2554 verir). + // PropertySchema'da InverseSide alani yok → iliskili Model'de BU Model'e geri-donen + // @ManyToOne'i bul ve `(r) => r.` ters-yonunu URET. Karsilikli ManyToOne yoksa + // cikarsanamaz → TODO birak. OneToOne/ManyToMany'de inverseSide opsiyonel → dokunma. + if (decorator === "OneToMany") { + const inverse = findInverseManyToOne(related, node, ctx); + if (!inverse) { + return ` // TODO: relation "${tsPropName(p.Name)}" (OneToMany -> ${p.RelatedModelRef}) — inverse side required (no reciprocal @ManyToOne found); add manually`; + } + imports.add(decorator, "typeorm"); + if (related.id !== node.id) { + imports.add(relatedClass, importPathOf(relativeImportPath(fromPath, filePathFor(related, ctx.graph)))); + } + const inverseVar = camelCase(related.name); + const inverseProp = tsPropName(inverse.Name); + const optional = p.IsNullable ? "?" : ""; + const assertion = optional ? "" : "!"; + const out: string[] = []; + out.push(` @OneToMany(() => ${relatedClass}, (${inverseVar}) => ${inverseVar}.${inverseProp})`); + out.push(` ${tsPropName(p.Name)}${optional}${assertion}: ${relatedClass}[];`); + return out.join("\n"); + } + imports.add(decorator, "typeorm"); + // Iliski tipini import et (kendi kendine iliski ise import gerekmez). + if (related.id !== node.id) { + imports.add(relatedClass, importPathOf(relativeImportPath(fromPath, filePathFor(related, ctx.graph)))); + } + + const tsType = p.IsCollection || p.RelationType === "OneToMany" || p.RelationType === "ManyToMany" + ? `${relatedClass}[]` + : relatedClass; + const optional = p.IsNullable ? "?" : ""; + // Zorunlu iliski alanlari da definite-assignment "!" alir (strict:true). + const assertion = optional ? "" : "!"; + + const out: string[] = []; + out.push(` @${decorator}(() => ${relatedClass})`); + out.push(` ${tsPropName(p.Name)}${optional}${assertion}: ${tsType};`); + return out.join("\n"); +} + +/** OneToMany ters-yonu: iliskili Model'in property'lerinde, BU Model'e (owner) + * geri-donen @ManyToOne'i ara. TypeORM `@OneToMany(() => R, r => r.)` + * ister; karsilikli ManyToOne yoksa ters-yon cikarsanamaz → null (cagiran TODO birakir). + * Deterministik: yalniz graf ref cozumu, I/O yok. */ +function findInverseManyToOne( + related: CodeNode, + owner: CodeNode, + ctx: Parameters[1], +): ModelProperty | null { + let relatedProps: ModelProps; + try { + relatedProps = propsOf<"Model">(related); + } catch { + return null; // related bir Model degilse (sentezlenmis/Table) ters-yon okunamaz + } + for (const rp of relatedProps.Properties ?? []) { + if (rp.RelationType !== "ManyToOne" || !rp.RelatedModelRef) continue; + const back = ctx.graph.resolveRef("Model", rp.RelatedModelRef); + if (back?.id === owner.id) return rp; + } + return null; +} + +/** @Column({ ... }) secenekleri (deterministik anahtar sirasi). columnOrmType + * (sql-type-map TEK SOURCE) ile entity/Table ile tutarli fiziksel tip. */ +function columnOptions( + p: ModelProperty, + ctx: Parameters[1], + imports: ImportCollector, + fromPath: string, +): string { + const parts: string[] = []; + // Enum-tipli kolon (#56): Type bir Enum node'una cozulurse @Column VARCHAR olur + // (native Postgres enum NOT) -> migration de VARCHAR + CHECK uretir, TUTARLI. + // cls yine import edilir cunku TS alan tipi (fieldDeclaration) generated enum sinifidir; + // DB-seviyesi deger kisiti migration'daki CHECK constraint'tedir. + const enumNode = ctx.graph.resolveRef("Enum", p.Type); + if (enumNode) { + const cls = pascalCase(enumNode.name); + imports.add(cls, importPathOf(relativeImportPath(fromPath, filePathFor(enumNode, ctx.graph)))); + parts.push(`type: "varchar"`); + } else { + parts.push(`type: ${JSON.stringify(columnOrmType(p.Type))}`); + } + if (p.IsNullable) parts.push("nullable: true"); + return `{ ${parts.join(", ")} }`; +} + +/** TypeScript alan bildirimi: "name: Type;" (nullable -> "?"). + * Zorunlu (initializer'siz) alanlar definite-assignment "!" alir; strict:true + * (strictPropertyInitialization) altinda TS2564 vermeden derlenir — TypeORM + * standardi. Opsiyonel "?" alanlar dokunulmaz. */ +function fieldDeclaration(p: ModelProperty, ctx: Parameters[1]): string { + const optional = p.IsNullable ? "?" : ""; + const assertion = optional ? "" : "!"; + // Enum-tipli alan: Type bir Enum node'una cozulurse o sinifi TS tipi yap + // (import @Column adiminda eklendi). Aksi halde serbest string oldugu gibi gecer. + const enumNode = ctx.graph.resolveRef("Enum", p.Type); + const base = enumNode ? pascalCase(enumNode.name) : sqlTypeToTs(p.Type, false); + const tsType = p.IsCollection ? `${base}[]` : base; + return `${tsPropName(p.Name)}${optional}${assertion}: ${tsType};`; +} + +/** Bir method -> imza + surgical govde (NOT_IMPLEMENTED). */ +function renderMethod(m: ModelMethod, className: string, node: CodeNode): string { + const visibility = m.Visibility && m.Visibility !== "public" ? `${m.Visibility} ` : ""; + const staticKw = m.IsStatic ? "static " : ""; + const asyncKw = m.IsAsync ? "async " : ""; + const params = (m.Parameters ?? []) + .map((param) => { + const opt = param.Optional ? "?" : ""; + const def = param.Default !== undefined && param.Default !== "" ? ` = ${param.Default}` : ""; + return `${param.Name}${opt}: ${scalarTsType(param.Type)}${def}`; + }) + .join(", "); + const ret = scalarTsType(m.ReturnType); + const signature = ` ${visibility}${staticKw}${asyncKw}${m.MethodName}(${params}): ${ret} {`; + + const marker = surgicalMarker({ nodeId: node.id, member: m.MethodName }); + const markerLines = marker + .split("\n") + .map((l) => ` ${l}`) + .join("\n"); + + return [signature, markerLines, ` ${notImplemented(className, m.MethodName)}`, " }"].join("\n"); +} diff --git a/apps/server/src/codegen/emitters/nestjs/module.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/module.emitter.spec.ts new file mode 100644 index 0000000..5fa2143 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/module.emitter.spec.ts @@ -0,0 +1,440 @@ +import { describe, it, expect } from "vitest"; +import { emitFeatureModule } from "./module.emitter"; +import { buildCodeGraph, type CodeGraph, type Feature } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; +import type { NodeKind } from "../../../nodes/schemas"; +import type { EdgeKind } from "../../../edges/schemas/edge.schema"; + +/* ──────────────────────────────────────────────────────────────────────── + * module.emitter.spec.ts — FEATURE-MODULE SENTEZI. + * + * Yeni API: emitFeatureModule(feature, ctx). Girdi artik ham Module node NOT, + * ir.ts feature-inference'in urettigi bir `Feature` tanimidir. Module node + * OLMASA bile her cikarilmis feature icin bir /.module.ts + * sentezlenir; app.module bunlari import eder -> DI tam, uygulama BOOT BOOTS. + * ──────────────────────────────────────────────────────────────────────── */ + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +let nodeSeq = 0; +function node(type: NodeKind, properties: Record, id?: string): StoredNode { + const n = (nodeSeq += 1); + return { + id: id ?? `0000000${n}-0000-4000-8000-000000000000`.slice(-36), + type, + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +let edgeSeq = 0; +function edge(kind: EdgeKind, sourceNodeId: string, targetNodeId: string): StoredEdge { + const n = (edgeSeq += 1); + return { + id: `e000000${n}-0000-4000-8000-000000000000`.slice(-36), + projectId: "00000000-0000-4000-8000-000000000000", + sourceNodeId, + targetNodeId, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: false }, + }; +} + +function ctxFor(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { + return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; +} + +function featureBySlug(graph: CodeGraph, slug: string): Feature { + const f = graph.features().find((x) => x.slug === slug); + if (!f) throw new Error(`feature '${slug}' not found: ${graph.features().map((x) => x.slug)}`); + return f; +} + +/* ── Gercekci "users" feature fixture'i (Module node NONE -> sentez) ───────── + * Controller -CALLS-> Service -CALLS-> Repository -WRITES-> Model(+Table). + * Feature-inference: tek "users" feature; controller/service/repository/entity + * hepsi bu feature'a atanir. */ +function usersFixture() { + const userModel = node("Model", { + ClassName: "User", + Description: "User varligi", + TableRef: "users", + Properties: [{ Name: "id", Type: "uuid", IsNullable: false, IsCollection: false }], + Methods: [], + }); + const usersTable = node("Table", { + TableName: "users", + Description: "User table", + Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true }], + }); + const usersRepo = node("Repository", { + RepositoryName: "UserRepository", + Description: "User veri erisimi", + EntityReference: "User", + IsCached: false, + CustomQueries: [], + }); + const usersService = node("Service", { + ServiceName: "UsersService", + Description: "User is mantigi", + IsTransactionScoped: false, + Methods: [{ MethodName: "findAll", ReturnType: "User[]", IsAsync: true }], + Dependencies: [{ Kind: "Repository", Ref: "UserRepository" }], + }); + const usersController = node("Controller", { + ControllerName: "UsersController", + Description: "User uclari", + BaseRoute: "users", + Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: false }], + }); + + const edges: StoredEdge[] = [ + edge("CALLS", usersController.id, usersService.id), + edge("CALLS", usersService.id, usersRepo.id), + edge("WRITES", usersRepo.id, usersTable.id), + ]; + + return { + nodes: [userModel, usersTable, usersRepo, usersService, usersController], + edges, + }; +} + +describe("emitFeatureModule", () => { + it("tam users modulu (Module node NONE -> sentez) — snapshot", () => { + const fx = usersFixture(); + const ctx = ctxFor(fx.nodes, fx.edges); + const [file] = emitFeatureModule(featureBySlug(ctx.graph, "users"), ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { Module } from "@nestjs/common"; + import { TypeOrmModule } from "@nestjs/typeorm"; + import { User } from "./entities/user.entity"; + import { UserRepository } from "./user.repository"; + import { UsersController } from "./users.controller"; + import { UsersService } from "./users.service"; + + /** Users feature module (synthesized by Solarch). */ + @Module({ + imports: [TypeOrmModule.forFeature([User])], + controllers: [UsersController], + providers: [UsersService, UserRepository], + }) + export class UsersModule {} + ", + "language": "typescript", + "path": "users/users.module.ts", + "surgicalMarkers": 0, + } + `); + }); + + it("dosya yolu /.module.ts (feature basina TEK module)", () => { + const fx = usersFixture(); + const ctx = ctxFor(fx.nodes, fx.edges); + const [file] = emitFeatureModule(featureBySlug(ctx.graph, "users"), ctx); + expect(file.path).toBe("users/users.module.ts"); + }); + + it("@Module dekoratoru + DI: controllers/providers + TypeOrmModule.forFeature", () => { + const fx = usersFixture(); + const ctx = ctxFor(fx.nodes, fx.edges); + const [file] = emitFeatureModule(featureBySlug(ctx.graph, "users"), ctx); + expect(file.content).toContain("@Module({"); + expect(file.content).toContain("controllers: [UsersController],"); + // providers = service'ler + repository'ler (DI tam; repository kayitli). + expect(file.content).toContain("providers: [UsersService, UserRepository],"); + expect(file.content).toContain("imports: [TypeOrmModule.forFeature([User])],"); + expect(file.content).toContain("export class UsersModule {}"); + }); + + it("import cozumleme: @nestjs/common + @nestjs/typeorm + feature-ici goreli importlar", () => { + const fx = usersFixture(); + const ctx = ctxFor(fx.nodes, fx.edges); + const [file] = emitFeatureModule(featureBySlug(ctx.graph, "users"), ctx); + expect(file.content).toContain('import { Module } from "@nestjs/common";'); + expect(file.content).toContain('import { TypeOrmModule } from "@nestjs/typeorm";'); + expect(file.content).toContain('import { UsersService } from "./users.service";'); + expect(file.content).toContain('import { UserRepository } from "./user.repository";'); + expect(file.content).toContain('import { User } from "./entities/user.entity";'); + // Paketler once, goreli sonra (import siralamasi). + const pkgIdx = file.content.indexOf("@nestjs/common"); + const relIdx = file.content.indexOf("./users.service"); + expect(pkgIdx).toBeGreaterThanOrEqual(0); + expect(pkgIdx).toBeLessThan(relIdx); + }); + + it("content ends with single newline, surgical marker yok", () => { + const fx = usersFixture(); + const ctx = ctxFor(fx.nodes, fx.edges); + const [file] = emitFeatureModule(featureBySlug(ctx.graph, "users"), ctx); + expect(file.content.endsWith("{}\n")).toBe(true); + expect(file.content.endsWith("{}\n\n")).toBe(false); + expect(file.surgicalMarkers).toBe(0); + }); + + it("DETERMINISM: ayni feature iki kez -> byte-identical", () => { + const fx = usersFixture(); + const ctx = ctxFor(fx.nodes, fx.edges); + const a = emitFeatureModule(featureBySlug(ctx.graph, "users"), ctx)[0].content; + const b = emitFeatureModule(featureBySlug(ctx.graph, "users"), ctx)[0].content; + expect(a).toBe(b); + }); + + /* ── CROSS-FEATURE: bir feature baska feature'in service'ini cagirirsa ──── */ + it("cross-feature bagimlilik: dependsOn modulu import + kaynak feature service'i export eder", () => { + // image feature ImageService -CALLS-> AuthService (auth feature). Beklenen: + // - auth modulu AuthService'i EXPORT eder (baska feature kullaniyor). + // - image modulu AuthModule'u IMPORT eder (dependsOn=[auth]). + const authCtrl = node("Controller", { + ControllerName: "AuthController", + Description: "Kimlik uclari", + BaseRoute: "auth", + Endpoints: [], + }); + const authSvc = node("Service", { + ServiceName: "AuthService", + Description: "Kimlik mantigi", + Dependencies: [], + Methods: [], + }); + const imageCtrl = node("Controller", { + ControllerName: "ImageController", + Description: "Gorsel uclari", + BaseRoute: "image", + Endpoints: [], + }); + const imageSvc = node("Service", { + ServiceName: "ImageService", + Description: "Gorsel mantigi", + Dependencies: [], + Methods: [], + }); + const edges = [ + edge("CALLS", authCtrl.id, authSvc.id), + edge("CALLS", imageCtrl.id, imageSvc.id), + edge("CALLS", imageSvc.id, authSvc.id), // cross-feature + ]; + const ctx = ctxFor([authCtrl, authSvc, imageCtrl, imageSvc], edges); + + const authFile = emitFeatureModule(featureBySlug(ctx.graph, "auth"), ctx)[0]; + expect(authFile.content).toContain("exports: [AuthService],"); + + const imageFile = emitFeatureModule(featureBySlug(ctx.graph, "image"), ctx)[0]; + expect(imageFile.content).toContain("imports: [AuthModule],"); + expect(imageFile.content).toContain('import { AuthModule } from "../auth/auth.module";'); + }); + + /* ── KARSILIKLI (circular) import: geri-kenar forwardRef ile emit edilir ── */ + it("karsilikli cross-feature: geri-kenar forwardRef(() => X) ile emit edilir (boot circular yok)", () => { + // auth <-> image karsilikli CALLS. ir.ts geri-kenari ((to,from) en kucuk = + // image -> auth) forwardRef ile isaretler -> image modulu AuthModule'u + // forwardRef(() => AuthModule) ile import BOOTS (kenar KORUNUR); auth duz import BOOTS. + const authCtrl = node("Controller", { ControllerName: "AuthController", Description: "x", BaseRoute: "auth", Endpoints: [] }); + const authSvc = node("Service", { ServiceName: "AuthService", Description: "x", Dependencies: [], Methods: [] }); + const imageCtrl = node("Controller", { ControllerName: "ImageController", Description: "x", BaseRoute: "image", Endpoints: [] }); + const imageSvc = node("Service", { ServiceName: "ImageService", Description: "x", Dependencies: [], Methods: [] }); + const edges = [ + edge("CALLS", authCtrl.id, authSvc.id), + edge("CALLS", imageCtrl.id, imageSvc.id), + edge("CALLS", imageSvc.id, authSvc.id), // image -> auth + edge("CALLS", authSvc.id, imageSvc.id), // auth -> image (karsilikli) + ]; + const ctx = ctxFor([authCtrl, authSvc, imageCtrl, imageSvc], edges); + + const authFile = emitFeatureModule(featureBySlug(ctx.graph, "auth"), ctx)[0]; + const imageFile = emitFeatureModule(featureBySlug(ctx.graph, "image"), ctx)[0]; + + // Eager yon (auth -> image) duz import KORUNUR (forwardRef NONE bu yonde). + expect(authFile.content).toContain("imports: [ImageModule],"); + expect(authFile.content).toContain('import { ImageModule } from "../image/image.module";'); + expect(authFile.content).not.toContain("forwardRef"); + // Geri-kenar (image -> auth) forwardRef ile emit edilir; KENAR KORUNUR (provider import'u kaybolmaz). + expect(imageFile.content).toContain("imports: [forwardRef(() => AuthModule)],"); + expect(imageFile.content).toContain('import { Module, forwardRef } from "@nestjs/common";'); + expect(imageFile.content).toContain('import { AuthModule } from "../auth/auth.module";'); + // DI yine saglam: iki servis de export edilir. + expect(authFile.content).toContain("exports: [AuthService],"); + expect(imageFile.content).toContain("exports: [ImageService],"); + }); + + /* ── Acik Module node feature'i tohumlarsa Description KORUNUR ──────────── */ + it("acik Module node varsa: feature slug'i tohumlar + Description korunur", () => { + const mod = node("Module", { + ModuleName: "AuthModule", + Description: "Kimlik dogrulama modulu", + StrictBoundaries: true, + ExposedServices: ["AuthService"], + Dependencies: [], + }); + const ctrl = node("Controller", { + ControllerName: "AuthController", + Description: "Kimlik uclari", + BaseRoute: "auth", + Endpoints: [], + }); + const svc = node("Service", { + ServiceName: "AuthService", + Description: "Kimlik mantigi", + Dependencies: [], + Methods: [], + }); + const edges = [edge("CALLS", ctrl.id, svc.id), edge("USES", mod.id, svc.id)]; + const ctx = ctxFor([mod, ctrl, svc], edges); + const feature = featureBySlug(ctx.graph, "auth"); + expect(feature.module?.id).toBe(mod.id); + const [file] = emitFeatureModule(feature, ctx); + // Module.Description -> dosya basi yorumu (sentez varsayilan metni NOT). + expect(file.content).toContain("/** Kimlik dogrulama modulu */"); + expect(file.content).toContain("export class AuthModule {}"); + expect(file.path).toBe("auth/auth.module.ts"); + }); + + /* ── CROSS-FEATURE Service->Repository: owner modul Repository'yi EXPORT eder ── */ + it("cross-feature Service->Repository: owner modul Repository'yi EXPORT eder, tuketici dependsOn", () => { + // image feature ImageGenerationService -CALLS-> UserRepository (auth feature). + // Beklenen: AuthModule UserRepository'yi EXPORT eder (NestJS'te export edilmeyen + // provider modul-disi gorunmez -> bootta DI hatasi); ImageModule AuthModule import. + const authCtrl = node("Controller", { ControllerName: "AuthController", Description: "x", BaseRoute: "auth", Endpoints: [] }); + const authSvc = node("Service", { ServiceName: "AuthService", Description: "x", Dependencies: [{ Kind: "Repository", Ref: "UserRepository" }], Methods: [] }); + const userRepo = node("Repository", { RepositoryName: "UserRepository", Description: "x", EntityReference: "User", CustomQueries: [] }); + const userModel = node("Model", { ClassName: "User", Description: "x", Properties: [{ Name: "id", Type: "uuid" }], Methods: [] }); + const imageCtrl = node("Controller", { ControllerName: "ImageController", Description: "x", BaseRoute: "image", Endpoints: [] }); + const imageSvc = node("Service", { ServiceName: "ImageGenerationService", Description: "x", Dependencies: [{ Kind: "Repository", Ref: "UserRepository" }], Methods: [] }); + const edges = [ + edge("CALLS", authCtrl.id, authSvc.id), + edge("CALLS", authSvc.id, userRepo.id), + edge("CALLS", imageCtrl.id, imageSvc.id), + edge("CALLS", imageSvc.id, userRepo.id), // CROSS-FEATURE Service->Repository + ]; + const ctx = ctxFor([authCtrl, authSvc, userRepo, userModel, imageCtrl, imageSvc], edges); + + // UserRepository hangi feature'a dustu? (firstSourceFeature isimce ilk = auth.) + const authFeature = ctx.graph.features().find((f) => f.repositories.some((r) => r.name === "UserRepository")); + expect(authFeature?.slug).toBe("auth"); + + const authFile = emitFeatureModule(featureBySlug(ctx.graph, "auth"), ctx)[0]; + // Repository EXPORT edilir (Service degil sadece -> Repository de aday). + expect(authFile.content).toContain("exports: [UserRepository],"); + + const imageFile = emitFeatureModule(featureBySlug(ctx.graph, "image"), ctx)[0]; + expect(imageFile.content).toContain("imports: [AuthModule],"); + }); + + /* ── Enjekte edilen Cache/ExternalService TAM provider'lari module'da ───── */ + it("CACHES_IN/REQUESTS edge'li Service -> Cache/ExternalService TAM provider + module import'lari", () => { + const ctrl = node("Controller", { ControllerName: "ImageController", Description: "x", BaseRoute: "image", Endpoints: [] }); + const svc = node("Service", { + ServiceName: "ImageService", + Description: "x", + Dependencies: [{ Kind: "Cache", Ref: "ImageCache" }, { Kind: "ExternalService", Ref: "SdApi" }], + Methods: [], + }); + const cache = node("Cache", { CacheName: "ImageCache", Description: "x", KeyPattern: "img:{id}", TTL_Seconds: 60, Engine: "Redis" }); + const ext = node("ExternalService", { ServiceName: "SdApi", Description: "x", BaseURL: "https://sd.example.com", AuthType: "None", TimeoutSeconds: 10, Endpoints: [] }); + const edges = [ + edge("CALLS", ctrl.id, svc.id), + edge("CACHES_IN", svc.id, cache.id), + edge("REQUESTS", svc.id, ext.id), + ]; + const ctx = ctxFor([ctrl, svc, cache, ext], edges); + const [file] = emitFeatureModule(featureBySlug(ctx.graph, "image"), ctx); + // Cache/ExternalService artik TAM emitter -> gercek sinif adi (Stub eki NONE). + expect(file.content).toContain("providers: [ImageService, ImageCache, SdApi],"); + expect(file.content).not.toContain("Stub"); + expect(file.content).toContain('import { ImageCache } from "./image.cache";'); + expect(file.content).toContain('import { SdApi } from "./sd.client";'); + // Module-seviyesi altyapi import'lari: CacheModule + HttpModule + ConfigModule. + expect(file.content).toContain("CacheModule.register()"); + expect(file.content).toContain("HttpModule"); + expect(file.content).toContain("ConfigModule"); + }); + + /* ── Table-only (Model'siz) feature: sentetik entity forFeature'a girer ──── */ + it("Model'siz Table feature: sentetik entity TypeOrmModule.forFeature'a eklenir", () => { + const ctrl = node("Controller", { ControllerName: "ImageController", Description: "x", BaseRoute: "image", Endpoints: [] }); + const svc = node("Service", { ServiceName: "ImageService", Description: "x", Dependencies: [{ Kind: "Repository", Ref: "ImageRepository" }], Methods: [] }); + const repo = node("Repository", { RepositoryName: "ImageRepository", Description: "x", EntityReference: "GeneratedImages", CustomQueries: [] }); + const table = node("Table", { TableName: "GeneratedImages", Description: "x", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }] }); + const edges = [edge("CALLS", ctrl.id, svc.id), edge("CALLS", svc.id, repo.id), edge("WRITES", repo.id, table.id)]; + const ctx = ctxFor([ctrl, svc, repo, table], edges); + const [file] = emitFeatureModule(featureBySlug(ctx.graph, "image"), ctx); + // Model NONE ama sentetik GeneratedImage entity forFeature'a girer -> DI tam. + expect(file.content).toContain("TypeOrmModule.forFeature([GeneratedImage])"); + expect(file.content).toContain('import { GeneratedImage } from "./entities/generated-image.entity";'); + expect(file.content).toContain("providers: [ImageService, ImageRepository],"); + }); + + /* ── #7 cross-feature infra provider TEK module'de provider (singleton) ──── */ + it("cift-inject: PaymentGateway YALNIZ payment module'unde provider; order import eder", () => { + // payment + order IKISI de PaymentGateway (ExternalService) enjekte eder. + // Eski hata: gateway iki module'un de providers'inda -> iki ornek (singleton kirik). + // Beklenen: yalniz PaymentModule provider+export eder; OrderModule PaymentModule import. + const payCtrl = node("Controller", { ControllerName: "PaymentController", Description: "x", BaseRoute: "payment", Endpoints: [] }); + const paySvc = node("Service", { ServiceName: "PaymentService", Description: "x", Dependencies: [{ Kind: "ExternalService", Ref: "PaymentGateway" }], Methods: [] }); + const orderCtrl = node("Controller", { ControllerName: "OrderController", Description: "x", BaseRoute: "order", Endpoints: [] }); + const orderSvc = node("Service", { ServiceName: "OrderService", Description: "x", Dependencies: [{ Kind: "ExternalService", Ref: "PaymentGateway" }], Methods: [] }); + const gw = node("ExternalService", { ServiceName: "PaymentGateway", Description: "x", BaseURL: "https://pg.example.com", AuthType: "None", TimeoutSeconds: 10, Endpoints: [] }); + const edges = [ + edge("CALLS", payCtrl.id, paySvc.id), + edge("CALLS", orderCtrl.id, orderSvc.id), + edge("REQUESTS", paySvc.id, gw.id), + edge("REQUESTS", orderSvc.id, gw.id), + edge("CALLS", orderSvc.id, paySvc.id), + ]; + const ctx = ctxFor([payCtrl, paySvc, orderCtrl, orderSvc, gw], edges); + + const paymentFile = emitFeatureModule(featureBySlug(ctx.graph, "payment"), ctx)[0]; + const orderFile = emitFeatureModule(featureBySlug(ctx.graph, "order"), ctx)[0]; + + // Sahip (payment): PaymentGateway providers + exports + import edilen sinif. + expect(paymentFile.content).toContain("providers: [PaymentService, PaymentGateway],"); + expect(paymentFile.content).toContain("exports: [PaymentService, PaymentGateway],"); + expect(paymentFile.content).toContain('import { PaymentGateway } from "./payment-gateway.client";'); + + // Sahip-DISI (order): PaymentGateway'i providers'a YAZMAZ, sinifi import ETMEZ. + expect(orderFile.content).not.toContain("PaymentGateway"); + expect(orderFile.content).toContain("providers: [OrderService],"); + // PaymentModule'u import eder (gateway + PaymentService oradan gelir). + expect(orderFile.content).toContain("imports: [PaymentModule],"); + expect(orderFile.content).toContain('import { PaymentModule } from "../payment/payment.module";'); + + // forwardRef ASLA uretilmez; dongu yok. + expect(paymentFile.content).not.toContain("forwardRef"); + expect(orderFile.content).not.toContain("forwardRef"); + }); + + it("entity yoksa imports alani atlanir (bos @Module alanlari yazilmaz)", () => { + // Controller + Service var ama Model/Table NONE -> TypeOrmModule.forFeature yok, + // cross-feature bagimlilik yok -> imports alani tamamen atlanir. + const ctrl = node("Controller", { + ControllerName: "PingController", + Description: "Saglik", + BaseRoute: "ping", + Endpoints: [], + }); + const svc = node("Service", { + ServiceName: "PingService", + Description: "Saglik mantigi", + Dependencies: [], + Methods: [], + }); + const ctx = ctxFor([ctrl, svc], [edge("CALLS", ctrl.id, svc.id)]); + const [file] = emitFeatureModule(featureBySlug(ctx.graph, "ping"), ctx); + expect(file.content).not.toContain("imports:"); + expect(file.content).not.toContain("TypeOrmModule"); + expect(file.content).not.toContain("exports:"); + expect(file.content).toContain("controllers: [PingController],"); + expect(file.content).toContain("providers: [PingService],"); + expect(file.content).toContain("export class PingModule {}"); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/module.emitter.ts b/apps/server/src/codegen/emitters/nestjs/module.emitter.ts new file mode 100644 index 0000000..75d3cba --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/module.emitter.ts @@ -0,0 +1,357 @@ +import type { EmitterContext, GeneratedFile } from "../../types"; +import type { CodeGraph, CodeNode, Feature } from "../../ir"; +import { filePathFor, pascalCase, relativeImportPath, importPathOf } from "../../naming"; +import { ImportCollector } from "../../imports"; +import { + entityClassNameForTable, + synthEntityFilePath, +} from "./entity-synthesis"; +import { stubClassName, stubFilePath } from "./stub.emitter"; +import { queueNameConst } from "./message-queue.emitter"; +import { countSurgicalMarkers } from "../../surgical"; + +/* ──────────────────────────────────────────────────────────────────────── + * module.emitter.ts — FEATURE -> /.module.ts (SYNTHESIS). + * + * ARCHITECTURE-AWARE: even WITHOUT a Module node, one NestJS @Module is SYNTHESIZED + * per extracted feature. Input is NOT a raw Module node but a `Feature` definition + * produced by ir.ts feature inference: + * + * @Module({ + * imports: [TypeOrmModule.forFeature([]), + * CacheModule.register(), (if Cache) + * HttpModule, ConfigModule, (if ExternalService) + * BullModule.registerQueue({ name: Q }), (MessageQueue/queue-handler) + * ...], + * controllers: [], + * providers: [], + * exports: [], + * }) + * export class Module [implements NestModule] {} + * + * When middleware exists the module `implements NestModule` and `configure(consumer)` + * binds each middleware to controllers it ROUTES_TO (or Global -> '*') + * via apply(...).forRoutes(...) (sorted by ExecutionOrder). + * + * PURE + DETERMINISTIC: all collections sorted by name/slug (Feature already + * arrives sorted), imports via ImportCollector, content ends with single "\n". + * @Module class is body-less when no middleware -> no surgical markers. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Emit .module.ts from a Feature definition (independent of Module node). */ +export function emitFeatureModule(feature: Feature, ctx: EmitterContext): GeneratedFile[] { + const graph = ctx.graph; + const className = `${pascalCase(feature.slug)}Module`; + // Module file path: treat as if there is no synthetic Module node for the feature — + // derive directly from feature layout (one module/feature), not via filePathFor. + const selfPath = `${feature.slug}/${feature.slug}.module.ts`; + + const imports = new ImportCollector(); + imports.add("Module", "@nestjs/common"); + + const { + controllers, + gateways, + services, + repositories, + entities, + syntheticEntityTables, + stubProviders, + infraProviders, + middlewares, + exports, + dependsOn, + forwardRefDeps, + } = feature; + + // APIGateway is a real @Controller -> goes in @Module.controllers WITH + // Controllers (NOT provider); NestJS routing wires it automatically (no orphan). + const allControllers = [...controllers, ...gateways]; + + // ── Import provider/controller symbols (same feature folder) ── + const realProviders = [...services, ...repositories]; + for (const n of [...allControllers, ...realProviders]) { + const cls = pascalCase(n.name); + if (cls.length === 0) continue; + imports.add(cls, importPathOf(relativeImportPath(selfPath, filePathFor(n, graph)))); + } + + // ── Infra providers (Cache/ExternalService/Worker/EventHandler/ + // Orchestrator/MessageQueue) — FULL @Injectable() classes (no Stub suffix). + // Each imported from its own file (filePathFor) + added to providers. ── + const infraProviderClasses: string[] = []; + for (const n of infraProviders) { + const cls = pascalCase(n.name); + if (cls.length === 0) continue; + imports.add(cls, importPathOf(relativeImportPath(selfPath, filePathFor(n, graph)))); + infraProviderClasses.push(cls); + } + + // ── Middleware — @Injectable() classes; added to providers + wired in configure() + // via apply().forRoutes(...). ── + const middlewareWiring = collectMiddlewareWiring(middlewares, graph, selfPath, imports); + const middlewareClasses = middlewareWiring.map((m) => m.className); + + // ── Stub providers (if any): @Injectable() stub classes. Cache/ + // ExternalService now have full emitters -> this list is practically EMPTY; mechanism + // kept for future stub kinds. Class name/path SINGLE SOURCE via stub.emitter + // (stubClassName/stubFilePath). ── + const stubProviderClasses: string[] = []; + for (const sp of stubProviders) { + const cls = stubClassName(sp); + if (cls.length === 0) continue; + imports.add(cls, importPathOf(relativeImportPath(selfPath, stubFilePath(sp, graph)))); + stubProviderClasses.push(cls); + } + + // ── TypeOrmModule.forFeature([Model entities + synthesized from Table]) ── + const entityClasses: string[] = []; + for (const ent of entities) { + const cls = pascalCase(ent.name); + if (cls.length === 0) continue; + imports.add(cls, importPathOf(relativeImportPath(selfPath, filePathFor(ent, graph)))); + entityClasses.push(cls); + } + for (const table of syntheticEntityTables) { + const cls = entityClassNameForTable(table); + if (cls.length === 0) continue; + imports.add(cls, importPathOf(relativeImportPath(selfPath, synthEntityFilePath(table, graph)))); + entityClasses.push(cls); + } + if (entityClasses.length > 0) { + imports.add("TypeOrmModule", "@nestjs/typeorm"); + } + + // ── Cross-feature dependent feature modules (import) ── + // CYCLE edges (forwardRefDeps) emitted as `forwardRef(() => XModule)` + // -> NestJS lazily resolves circular module dependency at boot. Edge PRESERVED + // (provider import not lost); only reference deferred. + const forwardRefSet = new Set(forwardRefDeps); + const depModuleClasses: string[] = []; + for (const depSlug of dependsOn) { + const depClass = `${pascalCase(depSlug)}Module`; + const depPath = `${depSlug}/${depSlug}.module.ts`; + imports.add(depClass, importPathOf(relativeImportPath(selfPath, depPath))); + if (forwardRefSet.has(depSlug)) { + imports.add("forwardRef", "@nestjs/common"); + depModuleClasses.push(`forwardRef(() => ${depClass})`); + } else { + depModuleClasses.push(depClass); + } + } + + // ── Infra module-level imports (by kind, deterministic) ── + // CacheModule.register() (if Cache); HttpModule + ConfigModule + // (if ExternalService); BullModule.registerQueue({ name: Q }) (MessageQueue + // producer + queue-based EventHandler, ONE SOURCE const per queue). + const infraImportEntries = collectInfraModuleImports(infraProviders, graph, selfPath, imports); + + // ── @Module decorator fields ── + const importEntries: string[] = []; + if (entityClasses.length > 0) { + importEntries.push(`TypeOrmModule.forFeature([${entityClasses.join(", ")}])`); + } + importEntries.push(...infraImportEntries); + importEntries.push(...depModuleClasses); + + // providers = real service/repository + infra + middleware + (if any) stub. + const providerClasses = [ + ...realProviders.map((p) => pascalCase(p.name)), + ...infraProviderClasses, + ...middlewareClasses, + ...stubProviderClasses, + ]; + + const decoratorLines: string[] = []; + pushArrayField(decoratorLines, "imports", importEntries); + pushArrayField(decoratorLines, "controllers", allControllers.map((c) => pascalCase(c.name))); + pushArrayField(decoratorLines, "providers", providerClasses); + // exports: Service/Repository AND infra providers (cross-feature + // injection targets). In NestJS unexported providers are invisible outside the module. + pushArrayField(decoratorLines, "exports", exports.map((e) => pascalCase(e.name))); + + const lines: string[] = []; + const description = feature.module + ? (feature.module.properties as Record).Description + : undefined; + if (typeof description === "string" && description.length > 0) { + lines.push(`/** ${description} */`); + } else { + lines.push(`/** ${pascalCase(feature.slug)} feature module (synthesized by Solarch). */`); + } + lines.push("@Module({"); + lines.push(...decoratorLines); + lines.push("})"); + + if (middlewareWiring.length > 0) { + // When middleware exists module implements NestModule + configure(consumer). + imports.add("MiddlewareConsumer", "@nestjs/common"); + imports.add("NestModule", "@nestjs/common"); + lines.push(`export class ${className} implements NestModule {`); + lines.push(" configure(consumer: MiddlewareConsumer): void {"); + for (const w of middlewareWiring) { + lines.push(` consumer.apply(${w.className}).forRoutes(${w.forRoutes});`); + } + lines.push(" }"); + lines.push("}"); + } else { + lines.push(`export class ${className} {}`); + } + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: selfPath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +} + +/* ── Infra module imports ───────────────────────────────────── + * Produce deterministic @Module.imports entries by kind + add required + * symbols to ImportCollector. Order FIXED (BullModule queues sorted by + * queue const name). ──────────────────────────────────────── */ +function collectInfraModuleImports( + infraProviders: CodeNode[], + graph: CodeGraph, + selfPath: string, + imports: ImportCollector, +): string[] { + const entries: string[] = []; + + const hasCache = infraProviders.some((n) => n.kindOf() === "Cache"); + const hasExternal = infraProviders.some((n) => n.kindOf() === "ExternalService"); + + // CacheModule.register() — resolves CACHE_MANAGER token (store binding at app + // root; feature-level register here is enough, token available at boot). + if (hasCache) { + imports.add("CacheModule", "@nestjs/cache-manager"); + entries.push("CacheModule.register()"); + } + // ExternalService -> HttpModule (HttpService) + ConfigModule (ConfigService). + if (hasExternal) { + imports.add("HttpModule", "@nestjs/axios"); + imports.add("ConfigModule", "@nestjs/config"); + entries.push("HttpModule"); + entries.push("ConfigModule"); + } + + // BullModule.registerQueue({ name: }) — for EVERY queue in this feature. + // Queue const imported from .queue.ts (SINGLE SOURCE for value). Includes MessageQueue + // producers + queues listened to by queue-based EventHandlers. + const queueNodes = collectFeatureQueues(infraProviders, graph); + if (queueNodes.length > 0) { + imports.add("BullModule", "@nestjs/bullmq"); + for (const q of queueNodes) { + const constName = queueNameConst(q); + imports.add(constName, importPathOf(relativeImportPath(selfPath, filePathFor(q, graph)))); + entries.push(`BullModule.registerQueue({ name: ${constName} })`); + } + } + + return entries; +} + +/** MessageQueue nodes this feature must register: MessageQueue producers belonging + * directly to the feature ∪ queues listened to by queue-based EventHandlers in the + * feature via SUBSCRIBES (else QueueRef). DEDUP + sorted by queue const name + * (deterministic). */ +function collectFeatureQueues(infraProviders: CodeNode[], graph: CodeGraph): CodeNode[] { + const byId = new Map(); + for (const n of infraProviders) { + if (n.kindOf() === "MessageQueue") byId.set(n.id, n); + if (n.kindOf() === "EventHandler") { + const q = resolveHandlerQueue(n, graph); + if (q) byId.set(q.id, q); + } + } + return [...byId.values()].sort((a, b) => { + const ca = queueNameConst(a); + const cb = queueNameConst(b); + return ca < cb ? -1 : ca > cb ? 1 : 0; + }); +} + +/** MessageQueue listened to by a queue-based EventHandler: SUBSCRIBES edge + * (else QueueRef property). Same resolution as event-handler.emitter. */ +function resolveHandlerQueue(handler: CodeNode, graph: CodeGraph): CodeNode | null { + for (const e of graph.outEdges(handler.id, "SUBSCRIBES")) { + const tgt = graph.byId(e.targetNodeId); + if (tgt && tgt.kindOf() === "MessageQueue") return tgt; + } + const queueRef = (handler.properties as Record).QueueRef; + if (typeof queueRef === "string" && queueRef.length > 0) { + const q = graph.resolveRef("MessageQueue", queueRef); + if (q) return q; + } + return null; +} + +/* ── Middleware wiring (NestModule.configure) ───────────────────────────── + * For each Middleware: import class + produce forRoutes(...) for controllers + * it ROUTES_TO. AppliesTo==="Global" (or no ROUTES_TO) -> forRoutes("*"). + * Multiple middleware sorted by ExecutionOrder (lower first). ──────── */ +interface MiddlewareWiring { + className: string; + /** forRoutes(...) argument expression (controller list or "*"). */ + forRoutes: string; +} + +function collectMiddlewareWiring( + middlewares: CodeNode[], + graph: CodeGraph, + selfPath: string, + imports: ImportCollector, +): MiddlewareWiring[] { + const wirings: { order: number; name: string; wiring: MiddlewareWiring }[] = []; + + for (const mw of middlewares) { + const className = pascalCase(mw.name); + if (className.length === 0) continue; + imports.add(className, importPathOf(relativeImportPath(selfPath, filePathFor(mw, graph)))); + + const props = mw.properties as Record; + const appliesTo = props.AppliesTo; + const executionOrder = typeof props.ExecutionOrder === "number" ? props.ExecutionOrder : 0; + + // ROUTES_TO -> Controller (deterministic order). AppliesTo==="Global" or + // no ROUTES_TO -> all routes ("*"). + const routedControllers: CodeNode[] = []; + for (const e of graph.outEdges(mw.id, "ROUTES_TO")) { + const tgt = graph.byId(e.targetNodeId); + if (tgt && tgt.kindOf() === "Controller") routedControllers.push(tgt); + } + + let forRoutes: string; + if (appliesTo === "Global" || routedControllers.length === 0) { + forRoutes = `"*"`; + } else { + const ctrlClasses = routedControllers.map((c) => pascalCase(c.name)); + for (const c of routedControllers) { + imports.add( + pascalCase(c.name), + importPathOf(relativeImportPath(selfPath, filePathFor(c, graph))), + ); + } + forRoutes = ctrlClasses.join(", "); + } + + wirings.push({ order: executionOrder, name: className, wiring: { className, forRoutes } }); + } + + // ExecutionOrder lower first; tie-break by class name (deterministic). + wirings.sort((a, b) => (a.order !== b.order ? a.order - b.order : a.name < b.name ? -1 : 1)); + return wirings.map((w) => w.wiring); +} + +/** Emit @Module decorator field. Empty entries -> field omitted entirely. */ +function pushArrayField(out: string[], field: string, entries: string[]): void { + if (entries.length === 0) return; + out.push(` ${field}: [${entries.join(", ")}],`); +} + +/* CodeGraph/CodeNode type references (kept for consumers outside emitters). */ +export type { CodeGraph, CodeNode }; diff --git a/apps/server/src/codegen/emitters/nestjs/orchestrator.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/orchestrator.emitter.spec.ts new file mode 100644 index 0000000..f716c03 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/orchestrator.emitter.spec.ts @@ -0,0 +1,312 @@ +import { describe, it, expect } from "vitest"; +import { emitOrchestrator } from "./orchestrator.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; +import type { EdgeKind } from "../../../edges/schemas/edge.schema"; + +/* ── Fixture helpers (service.emitter.spec ile ayni desen) ──────────── */ +const PROJECT = "00000000-0000-4000-8000-000000000000"; +const TAB = "22222222-2222-4222-8222-222222222222"; + +function node(type: StoredNode["type"], id: string, properties: Record): StoredNode { + return { + id, + type, + projectId: PROJECT, + positionX: 0, + positionY: 0, + homeTabId: TAB, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function edge(id: string, kind: EdgeKind, sourceNodeId: string, targetNodeId: string): StoredEdge { + return { + id, + projectId: PROJECT, + sourceNodeId, + targetNodeId, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: false }, + }; +} + +function ctxFrom(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { + return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; +} + +/* ── ID'ler ─────────────────────────────────────────────────────────────── */ +const ORCH = "10000000-0000-4000-8000-0000000000a1"; +const PAYMENT_SVC = "10000000-0000-4000-8000-0000000000a2"; +const INVENTORY_SVC = "10000000-0000-4000-8000-0000000000a3"; +const SHIPPING_SVC = "10000000-0000-4000-8000-0000000000a4"; +const CHECKOUT_CTRL = "10000000-0000-4000-8000-0000000000a5"; + +/* ── Node fixtures ──────────────────────────────────────────────────── */ +const paymentService = node("Service", PAYMENT_SVC, { + ServiceName: "PaymentService", + Description: "Odeme", + IsTransactionScoped: false, + Dependencies: [], + Methods: [], +}); + +const inventoryService = node("Service", INVENTORY_SVC, { + ServiceName: "InventoryService", + Description: "Stok", + IsTransactionScoped: false, + Dependencies: [], + Methods: [], +}); + +const shippingService = node("Service", SHIPPING_SVC, { + ServiceName: "ShippingService", + Description: "Kargo", + IsTransactionScoped: false, + Dependencies: [], + Methods: [], +}); + +// Bir Controller'in CALLS ettigi service'ler "checkout" feature'ini tohumlar; +// boylece orchestrator + service'ler ayni feature'a duser (goreli import kisa). +const checkoutController = node("Controller", CHECKOUT_CTRL, { + ControllerName: "CheckoutController", + Description: "Checkout API", + BasePath: "/checkout", + Endpoints: [], +}); + +const checkoutOrchestrator = node("Orchestrator", ORCH, { + OrchestratorName: "CheckoutOrchestrator", + Description: "Order approval saga", + Pattern: "Saga", + Steps: [ + { + StepName: "ReserveInventory", + ServiceRef: "InventoryService", + Action: "Reserve stock", + CompensationAction: "Release reservation", + OnFailure: "compensate", + }, + { + StepName: "ChargePayment", + ServiceRef: "PaymentService", + Action: "Collect payment", + CompensationAction: "Refund payment", + OnFailure: "compensate", + }, + { + StepName: "ScheduleShipment", + ServiceRef: "ShippingService", + Action: "Schedule shipment", + OnFailure: "retry", + }, + ], +}); + +/* Controller'in service'leri CALLS etmesi feature atamasi icin: checkout feature. + * Orchestrator da bu service'leri CALLS eder (DI). */ +function fullGraphEdges(): StoredEdge[] { + return [ + edge("e-c-pay", "CALLS", CHECKOUT_CTRL, PAYMENT_SVC), + edge("e-c-inv", "CALLS", CHECKOUT_CTRL, INVENTORY_SVC), + edge("e-c-shp", "CALLS", CHECKOUT_CTRL, SHIPPING_SVC), + edge("e-o-pay", "CALLS", ORCH, PAYMENT_SVC), + edge("e-o-inv", "CALLS", ORCH, INVENTORY_SVC), + edge("e-o-shp", "CALLS", ORCH, SHIPPING_SVC), + ]; +} + +describe("emitOrchestrator", () => { + it("tam orchestrator — snapshot (DI, dekorator, execute + adim metotlari, surgical marker)", () => { + const ctx = ctxFrom( + [checkoutOrchestrator, paymentService, inventoryService, shippingService, checkoutController], + fullGraphEdges(), + ); + const [file] = emitOrchestrator(ctx.graph.byId(ORCH)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { Injectable } from "@nestjs/common"; + import { InventoryService } from "./inventory.service"; + import { PaymentService } from "./payment.service"; + import { ShippingService } from "./shipping.service"; + + /** Order approval saga */ + @Injectable() + export class CheckoutOrchestrator { + constructor( + private readonly inventoryService: InventoryService, + private readonly paymentService: PaymentService, + private readonly shippingService: ShippingService, + ) {} + + async execute(): Promise { + // @solarch:surgical id=10000000-0000-4000-8000-0000000000a1#execute + // Saga orchestration: coordinates all steps. + // steps: ReserveInventory -> ChargePayment -> ScheduleShipment + // deps: this.inventoryService, this.paymentService, this.shippingService + throw new Error("NOT_IMPLEMENTED: CheckoutOrchestrator.execute"); + } + + async reserveInventory(): Promise { + // @solarch:surgical id=10000000-0000-4000-8000-0000000000a1#reserveInventory + // Reserve stock + // onFailure: compensate + // compensation: Release reservation + // deps: this.inventoryService + throw new Error("NOT_IMPLEMENTED: CheckoutOrchestrator.reserveInventory"); + } + + async chargePayment(): Promise { + // @solarch:surgical id=10000000-0000-4000-8000-0000000000a1#chargePayment + // Collect payment + // onFailure: compensate + // compensation: Refund payment + // deps: this.paymentService + throw new Error("NOT_IMPLEMENTED: CheckoutOrchestrator.chargePayment"); + } + + async scheduleShipment(): Promise { + // @solarch:surgical id=10000000-0000-4000-8000-0000000000a1#scheduleShipment + // Schedule shipment + // onFailure: retry + // deps: this.shippingService + throw new Error("NOT_IMPLEMENTED: CheckoutOrchestrator.scheduleShipment"); + } + } + ", + "language": "typescript", + "path": "checkout/checkout.orchestrator.ts", + "surgicalMarkers": 4, + } + `); + }); + + it("dosya yolu feature klasoru + rol-tekrarsiz .orchestrator.ts", () => { + const ctx = ctxFrom( + [checkoutOrchestrator, paymentService, inventoryService, shippingService, checkoutController], + fullGraphEdges(), + ); + const [file] = emitOrchestrator(ctx.graph.byId(ORCH)!, ctx); + // base "Checkout" (Orchestrator eki duser) -> checkout.orchestrator.ts. + expect(file.path).toBe("checkout/checkout.orchestrator.ts"); + }); + + it("DI = Steps[].ServiceRef ∪ CALLS hedefleri, DEDUP + isme gore sirali", () => { + // Steps'te 3 service ref + ayni service'lere CALLS edge -> her biri TEK alan. + const ctx = ctxFrom( + [checkoutOrchestrator, paymentService, inventoryService, shippingService, checkoutController], + fullGraphEdges(), + ); + const [file] = emitOrchestrator(ctx.graph.byId(ORCH)!, ctx); + // Her service tek kez enjekte edilir. + expect(file.content.split("private readonly inventoryService").length - 1).toBe(1); + expect(file.content.split("private readonly paymentService").length - 1).toBe(1); + expect(file.content.split("private readonly shippingService").length - 1).toBe(1); + // Isme gore sirali: inventory < payment < shipping. + const iInv = file.content.indexOf("inventoryService:"); + const iPay = file.content.indexOf("paymentService:"); + const iShp = file.content.indexOf("shippingService:"); + expect(iInv).toBeLessThan(iPay); + expect(iPay).toBeLessThan(iShp); + }); + + it("her adim icin + execute icin surgical marker + NOT_IMPLEMENTED", () => { + const ctx = ctxFrom( + [checkoutOrchestrator, paymentService, inventoryService, shippingService, checkoutController], + fullGraphEdges(), + ); + const [file] = emitOrchestrator(ctx.graph.byId(ORCH)!, ctx); + // 1 execute + 3 adim = 4 marker. + expect(file.surgicalMarkers).toBe(4); + expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: CheckoutOrchestrator.execute");'); + expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: CheckoutOrchestrator.reserveInventory");'); + expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: CheckoutOrchestrator.chargePayment");'); + expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: CheckoutOrchestrator.scheduleShipment");'); + }); + + it("CALLS edge'i olmadan da Steps[].ServiceRef'ten DI cozer + import uretir", () => { + // Hic CALLS edge yok; DI yalniz Steps[].ServiceRef'ten gelmeli. + const ctx = ctxFrom( + [checkoutOrchestrator, paymentService, inventoryService, shippingService, checkoutController], + [ + edge("e-c-pay", "CALLS", CHECKOUT_CTRL, PAYMENT_SVC), + edge("e-c-inv", "CALLS", CHECKOUT_CTRL, INVENTORY_SVC), + edge("e-c-shp", "CALLS", CHECKOUT_CTRL, SHIPPING_SVC), + ], + ); + const [file] = emitOrchestrator(ctx.graph.byId(ORCH)!, ctx); + expect(file.content).toContain("private readonly paymentService: PaymentService,"); + expect(file.content).toMatch(/import \{ PaymentService \} from ".*payment\.service"/); + }); + + it("edge-case: kayip ServiceRef — throw etmez, ham isimden sinif adi + import atlanir", () => { + const lonelyOrch = node("Orchestrator", ORCH, { + OrchestratorName: "GhostOrchestrator", + Description: "Kayip ref'li akis", + Pattern: "StateMachine", + Steps: [ + { + StepName: "DoThing", + ServiceRef: "MissingService", + Action: "do something", + OnFailure: "abort", + }, + ], + }); + const ctx = ctxFrom([lonelyOrch], []); + let file: { content: string; surgicalMarkers: number; path: string } | undefined; + expect(() => { + file = emitOrchestrator(ctx.graph.byId(ORCH)!, ctx)[0]; + }).not.toThrow(); + // Ham ref'ten sinif adi turetilir. + expect(file!.content).toContain("private readonly missingService: MissingService,"); + // Cozulemeyen service import EDILMEZ. + expect(file!.content).not.toMatch(/import \{ MissingService \}/); + // execute + 1 adim = 2 marker. + expect(file!.surgicalMarkers).toBe(2); + expect(file!.content).toContain('throw new Error("NOT_IMPLEMENTED: GhostOrchestrator.doThing");'); + }); + + it("edge-case: bos Steps — yine de execute uretir, constructor yok", () => { + const emptyOrch = node("Orchestrator", ORCH, { + OrchestratorName: "EmptyOrchestrator", + Description: "Adimsiz akis", + Pattern: "ProcessManager", + Steps: [], + }); + const ctx = ctxFrom([emptyOrch], []); + const [file] = emitOrchestrator(ctx.graph.byId(ORCH)!, ctx); + // DI yok -> constructor yok. + expect(file.content).not.toContain("constructor("); + // Yalniz execute() uretilir. + expect(file.content).toContain("async execute(): Promise {"); + expect(file.surgicalMarkers).toBe(1); + }); + + it("content ends with single newline", () => { + const ctx = ctxFrom( + [checkoutOrchestrator, paymentService, inventoryService, shippingService, checkoutController], + fullGraphEdges(), + ); + const [file] = emitOrchestrator(ctx.graph.byId(ORCH)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: two independent graph builds -> byte-identical", () => { + const nodes = [checkoutOrchestrator, paymentService, inventoryService, shippingService, checkoutController]; + const ctxA = ctxFrom(nodes, fullGraphEdges()); + const a = emitOrchestrator(ctxA.graph.byId(ORCH)!, ctxA)[0].content; + const ctxB = ctxFrom(nodes, fullGraphEdges()); + const b = emitOrchestrator(ctxB.graph.byId(ORCH)!, ctxB)[0].content; + expect(a).toBe(b); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/orchestrator.emitter.ts b/apps/server/src/codegen/emitters/nestjs/orchestrator.emitter.ts new file mode 100644 index 0000000..125d8e2 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/orchestrator.emitter.ts @@ -0,0 +1,251 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import { type CodeGraph, type CodeNode } from "../../ir"; +import { + camelCase, + filePathFor, + importPathOf, + pascalCase, + relativeImportPath, +} from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers, notImplemented, surgicalMarker } from "../../surgical"; +import type { OrchestratorNode } from "../../../nodes/schemas/orchestrator.schema"; + +/* ──────────────────────────────────────────────────────────────────────── + * orchestrator.emitter.ts — OrchestratorNode -> /.orchestrator.ts. + * + * Bir Orchestrator, birden cok Service'i bir IS AKISINA (Saga / state machine / + * process manager) baglayan @Injectable() koordinatordur. Kendi is mantigi + * yoktur — KOORDINE eder: enjekte ettigi Service'lerin metotlarini sirali (veya + * telafili) cagirir. + * + * DI alanlari (constructor): + * - Steps[].ServiceRef ile adlanan her Service node BIRLESIM + * graph.outEdges(id, "CALLS") hedeflerinden Service olanlar. DEDUP edilir, + * isme gore siralanir, `private readonly : ` olarak + * enjekte edilir. Cozulebilen ref'ler icin import eklenir; cozulemeyen ref'ler + * ham Ref isminden sinif adi turetir (import atlanir → ASLA throw). + * + * Metotlar: + * - execute(): orchestrator giris noktasi (tum akisi yurutur). Surgical govde; + * deps = enjekte edilen tum Service'ler. + * - Her Step icin bir metot (kebab/camel StepName). Surgical govde; deps = o + * adimi yuruten Service (ServiceRef). Description = "Action" (+ OnFailure / + * CompensationAction notlari). + * + * SAF + DETERMINISTIC: koleksiyonlar sirali (deps isme, metotlar Step sirasinda), + * import'lar ImportCollector ile, timestamp/random yok, icerik tek "\n" ile biter. + * + * NOT: Orchestrator PropsByKind icinde NOT — propsOf<...> KULLANILMAZ. + * properties OrchestratorNode["properties"] olarak tipli okunur (DB Zod-dogrulanmis). + * ──────────────────────────────────────────────────────────────────────── */ + +type OrchestratorProps = OrchestratorNode["properties"]; +type OrchestratorStep = OrchestratorProps["Steps"][number]; + +/** Cozulmus bir bagimlilik (enjekte edilen Service): DI alani + sinif tipi + + * (varsa) import yolu. */ +interface ResolvedServiceDep { + /** constructor / this. */ + field: string; + /** enjekte edilen sinif tipi (pascalCase(name)) */ + className: string; + /** cozulen node'un dosya yolu (import icin); cozulemezse null. */ + filePath: string | null; +} + +export const emitOrchestrator: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = node.properties as OrchestratorProps; + const className = pascalCase(node.name); + const filePath = filePathFor(node, ctx.graph); + const graph = ctx.graph; + + const imports = new ImportCollector(); + imports.add("Injectable", "@nestjs/common"); + + // ── DI bagimliliklari: Steps[].ServiceRef ∪ CALLS hedefleri (Service) ────── + // DEDUP (cozulen node.name veya ham ref) + isme gore sirali. Her step'in hangi + // service alanina karsilik geldigini metot govdesinde isaretleyebilmek icin + // ref -> field eslemesini de tutariz. + const { deps, fieldByRef } = collectServiceDeps(node, props, graph); + for (const dep of deps) { + if (dep.filePath) { + imports.add(dep.className, importPathOf(relativeImportPath(filePath, dep.filePath))); + } + } + const allDepFields = deps.map((d) => `this.${d.field}`); + + // ── Metotlar ────────────────────────────────────────────────────────────── + // (1) execute(): tum akisin giris noktasi (deps = tumu). + // (2) Her Step icin bir metot (deps = o adimin service'i). + const methodBlocks: string[] = [renderExecute(node, className, props, allDepFields)]; + for (const step of props.Steps ?? []) { + methodBlocks.push(renderStep(node, className, step, fieldByRef)); + } + + // ── Sinif govdesi ─────────────────────────────────────────────────────────── + const lines: string[] = []; + if (props.Description) lines.push(`/** ${props.Description} */`); + lines.push("@Injectable()"); + lines.push(`export class ${className} {`); + + if (deps.length > 0) { + lines.push(" constructor("); + for (const dep of deps) { + lines.push(` private readonly ${dep.field}: ${dep.className},`); + } + lines.push(" ) {}"); + if (methodBlocks.length > 0) lines.push(""); + } + + methodBlocks.forEach((block, i) => { + lines.push(block); + if (i < methodBlocks.length - 1) lines.push(""); + }); + + lines.push("}"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: filePath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/** Steps[].ServiceRef ∪ CALLS edge hedeflerini (Service) DEDUP edip isme gore + * siralanmis ResolvedServiceDep listesi + ref->field eslemesi dondurur. + * Cozulemeyen ServiceRef'ler ham isimden sinif adi turetir (filePath=null → + * import atlanir). Asla throw etmez. */ +function collectServiceDeps( + node: CodeNode, + props: OrchestratorProps, + graph: CodeGraph, +): { deps: ResolvedServiceDep[]; fieldByRef: Map } { + // refName (cozulen node.name veya ham ref) -> ResolvedServiceDep (DEDUP). + const byKey = new Map(); + // Her ham ServiceRef ismini -> DI alan adina esler (step govdesi icin). + const fieldByRef = new Map(); + + const register = (resolved: CodeNode | null, rawRef: string): string => { + const refName = resolved ? resolved.name : rawRef; + let entry = byKey.get(refName); + if (entry) { + // Mevcut cozulmemis + gelen cozulmus -> yukselt (import kaybini onle). + if (entry.filePath === null && resolved) { + entry.filePath = filePathFor(resolved, graph); + entry.className = pascalCase(resolved.name); + } + } else { + entry = { + field: camelCase(refName), + className: pascalCase(refName), + filePath: resolved ? filePathFor(resolved, graph) : null, + }; + byKey.set(refName, entry); + } + return entry.field; + }; + + // (1) Steps[].ServiceRef — her adimi yuruten Service. + for (const step of props.Steps ?? []) { + const ref = step.ServiceRef; + if (!ref) continue; + const resolved = graph.resolveRef("Service", ref); + const field = register(resolved, ref); + // Ham ServiceRef -> field (step govdesi this. isaretler). + if (!fieldByRef.has(ref)) fieldByRef.set(ref, field); + } + + // (2) CALLS edge hedefleri — Service olanlar (Steps'te gecmeyenler de DI'ya girer). + for (const e of graph.outEdges(node.id, "CALLS")) { + const tgt = graph.byId(e.targetNodeId); + if (!tgt || tgt.kindOf() !== "Service") continue; + const field = register(tgt, tgt.name); + if (!fieldByRef.has(tgt.name)) fieldByRef.set(tgt.name, field); + } + + const deps = [...byKey.values()].sort((a, b) => cmp(a.field, b.field)); + return { deps, fieldByRef }; +} + +/** execute() — orchestrator giris noktasi. Tum akisi (Steps sirasiyla) yurutur. + * deps = enjekte edilen TUM service alanlari. Surgical govde. */ +function renderExecute( + node: CodeNode, + className: string, + props: OrchestratorProps, + allDepFields: string[], +): string { + const indent = " "; + const stepNames = (props.Steps ?? []).map((s) => s.StepName).filter((n) => n.length > 0); + const descParts: string[] = []; + descParts.push(`${props.Pattern} orchestration: coordinates all steps.`); + if (stepNames.length > 0) descParts.push(`steps: ${stepNames.join(" -> ")}`); + + const marker = surgicalMarker({ + nodeId: node.id, + member: "execute", + description: descParts.join("\n"), + deps: allDepFields.length > 0 ? allDepFields : undefined, + }); + + const lines: string[] = []; + lines.push(`${indent}async execute(): Promise {`); + for (const ml of marker.split("\n")) lines.push(`${indent}${indent}${ml}`); + lines.push(`${indent}${indent}${notImplemented(className, "execute")}`); + lines.push(`${indent}}`); + return lines.join("\n"); +} + +/** Tek bir Step'i bir metoda cevirir (imza + surgical govde). Metot adi StepName' + * den camelCase turetilir; deps = o adimi yuruten Service alani (varsa). */ +function renderStep( + node: CodeNode, + className: string, + step: OrchestratorStep, + fieldByRef: Map, +): string { + const indent = " "; + const method = stepMethodName(step.StepName); + + // Bu adimi yuruten service alani (ServiceRef -> field). + const depFields: string[] = []; + const field = step.ServiceRef ? fieldByRef.get(step.ServiceRef) : undefined; + if (field) depFields.push(`this.${field}`); + + // Aciklama: Action + OnFailure + (varsa) CompensationAction. + const descParts: string[] = []; + if (step.Action) descParts.push(step.Action); + descParts.push(`onFailure: ${step.OnFailure}`); + if (step.CompensationAction) descParts.push(`compensation: ${step.CompensationAction}`); + + const marker = surgicalMarker({ + nodeId: node.id, + member: method, + description: descParts.join("\n"), + deps: depFields.length > 0 ? depFields : undefined, + }); + + const lines: string[] = []; + lines.push(`${indent}async ${method}(): Promise {`); + for (const ml of marker.split("\n")) lines.push(`${indent}${indent}${ml}`); + lines.push(`${indent}${indent}${notImplemented(className, method)}`); + lines.push(`${indent}}`); + return lines.join("\n"); +} + +/** Bir StepName'i gecerli bir TS metot adina cevirir: camelCase; bossa "step". */ +function stepMethodName(stepName: string): string { + const camel = camelCase(stepName); + return camel.length > 0 ? camel : "step"; +} + +/** Deterministik string karsilastirmasi. */ +function cmp(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} diff --git a/apps/server/src/codegen/emitters/nestjs/repository.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/repository.emitter.spec.ts new file mode 100644 index 0000000..006a041 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/repository.emitter.spec.ts @@ -0,0 +1,554 @@ +import { describe, it, expect } from "vitest"; +import { emitRepository } from "./repository.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { NodeKind } from "../../../nodes/schemas"; + +/* ── Fixture helpers (enum.emitter.spec deseni) ───────────────────── */ +function storedNode( + type: NodeKind, + properties: Record, + id: string, +): StoredNode { + return { + id, + type, + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function ctxFor(...nodes: StoredNode[]): { ctx: EmitterContext } { + const graph = buildCodeGraph(nodes, []); + return { ctx: { graph, target: "nestjs" } }; +} + +const REPO_ID = "33333333-3333-4333-8333-333333333333"; +const MODEL_ID = "44444444-4444-4444-8444-444444444444"; + +const USER_MODEL = storedNode( + "Model", + { + ClassName: "User", + Description: "User entity", + Properties: [{ Name: "id", Type: "string" }], + Methods: [], + }, + MODEL_ID, +); + +const USER_REPO = storedNode( + "Repository", + { + RepositoryName: "UserRepository", + Description: "User veri erisimi", + EntityReference: "User", + IsCached: false, + CustomQueries: [ + { + QueryName: "findByEmail", + QueryType: "findOne", + Parameters: [{ Name: "email", Type: "string" }], + ReturnType: "User | null", + Description: "E-postaya gore kullanici bul", + }, + { + QueryName: "countActive", + QueryType: "aggregate", + Parameters: [], + ReturnType: "number", + }, + ], + }, + REPO_ID, +); + +describe("emitRepository", () => { + it("Model entity ile tam uretim — snapshot", () => { + const { ctx } = ctxFor(USER_REPO, USER_MODEL); + const [file] = emitRepository(ctx.graph.byId(REPO_ID)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { Injectable } from "@nestjs/common"; + import { InjectRepository } from "@nestjs/typeorm"; + import { FindOptionsWhere, Repository } from "typeorm"; + import { User } from "./entities/user.entity"; + + /** User veri erisimi */ + @Injectable() + export class UserRepository { + constructor( + @InjectRepository(User) + private readonly repo: Repository, + ) {} + + async findById(id: string): Promise { + return this.repo.findOne({ + where: { id: id } as FindOptionsWhere, + relations: this.repo.metadata.relations.map((r) => r.propertyName), + }); + } + + async findAll(): Promise { + return this.repo.find(); + } + + async save(entity: User): Promise { + return this.repo.save(entity); + } + + async remove(id: string): Promise { + await this.repo.delete(id); + } + + async countActive(): Promise { + // @solarch:surgical id=33333333-3333-4333-8333-333333333333#countActive + // GUIDANCE: fetch related data in a SINGLE query via join/relations (leftJoinAndSelect or find({ relations })); avoid N+1 by not relying on lazy access inside a loop. + // deps: repo + throw new Error("NOT_IMPLEMENTED: UserRepository.countActive"); + } + + async findByEmail(email: string): Promise { + // @solarch:surgical id=33333333-3333-4333-8333-333333333333#findByEmail + // E-postaya gore kullanici bul + // GUIDANCE: fetch related data in a SINGLE query via join/relations (leftJoinAndSelect or find({ relations })); avoid N+1 by not relying on lazy access inside a loop. + // deps: repo + throw new Error("NOT_IMPLEMENTED: UserRepository.findByEmail"); + } + } + ", + "language": "typescript", + "path": "user/user.repository.ts", + "surgicalMarkers": 2, + } + `); + }); + + it("dogru import'lar, dekorator, DI ve entity tipi", () => { + const { ctx } = ctxFor(USER_REPO, USER_MODEL); + const [file] = emitRepository(ctx.graph.byId(REPO_ID)!, ctx); + expect(file.content).toContain(`import { Injectable } from "@nestjs/common";`); + expect(file.content).toContain(`import { InjectRepository } from "@nestjs/typeorm";`); + // Repository + FindOptionsWhere (standart CRUD findById where-cast'i icin). + expect(file.content).toContain(`import { FindOptionsWhere, Repository } from "typeorm";`); + expect(file.content).toContain(`import { User } from "./entities/user.entity";`); + expect(file.content).toContain("@Injectable()"); + expect(file.content).toContain("export class UserRepository {"); + expect(file.content).toContain("@InjectRepository(User)"); + expect(file.content).toContain("private readonly repo: Repository,"); + }); + + it("CustomQuery -> async imza + surgical marker + NOT_IMPLEMENTED", () => { + const { ctx } = ctxFor(USER_REPO, USER_MODEL); + const [file] = emitRepository(ctx.graph.byId(REPO_ID)!, ctx); + expect(file.content).toContain("async findByEmail(email: string): Promise {"); + expect(file.content).toContain(`id=${REPO_ID}#findByEmail`); + expect(file.content).toContain(`throw new Error("NOT_IMPLEMENTED: UserRepository.findByEmail");`); + expect(file.surgicalMarkers).toBe(2); + }); + + it("CustomQuery'ler isme gore sirali (countActive < findByEmail)", () => { + const { ctx } = ctxFor(USER_REPO, USER_MODEL); + const [file] = emitRepository(ctx.graph.byId(REPO_ID)!, ctx); + const idxCount = file.content.indexOf("async countActive"); + const idxFind = file.content.indexOf("async findByEmail"); + expect(idxCount).toBeGreaterThan(-1); + expect(idxFind).toBeGreaterThan(idxCount); + }); + + it("BaseClass cozulemez serbest ad -> extends URETILMEZ (TS2304 onlenir), TODO birakilir", () => { + const repo = storedNode( + "Repository", + { + RepositoryName: "OrderRepository", + Description: "Order erisimi", + EntityReference: "Order", + BaseClass: "BaseRepository", + IsCached: false, + CustomQueries: [], + }, + "55555555-5555-4555-8555-555555555555", + ); + const model = storedNode( + "Model", + { + ClassName: "Order", + Description: "Order", + Properties: [{ Name: "id", Type: "string" }], + Methods: [], + }, + "66666666-6666-4666-8666-666666666666", + ); + const { ctx } = ctxFor(repo, model); + const [file] = emitRepository(ctx.graph.byId(repo.id)!, ctx); + // BaseClass cozulebilir bir node NOT (import edilemez) -> `extends` uretmek + // TS2304 'Cannot find name BaseRepository' verirdi. Bunun yerine duz sinif + + // TODO yorumu (gelistirici elle ekler). + expect(file.content).toContain("export class OrderRepository {"); + expect(file.content).not.toContain("extends BaseRepository"); + expect(file.content).not.toContain("super();"); + expect(file.content).toContain('// TODO: BaseClass "BaseRepository"'); + }); + + it("dosya yolu feature klasoru + .repository.ts", () => { + const { ctx } = ctxFor(USER_REPO, USER_MODEL); + const [file] = emitRepository(ctx.graph.byId(REPO_ID)!, ctx); + expect(file.path).toBe("user/user.repository.ts"); + expect(file.language).toBe("typescript"); + }); + + it("content ends with single newline", () => { + const { ctx } = ctxFor(USER_REPO, USER_MODEL); + const [file] = emitRepository(ctx.graph.byId(REPO_ID)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: same node twice -> byte-identical", () => { + const { ctx } = ctxFor(USER_REPO, USER_MODEL); + const a = emitRepository(ctx.graph.byId(REPO_ID)!, ctx)[0].content; + const b = emitRepository(ctx.graph.byId(REPO_ID)!, ctx)[0].content; + expect(a).toBe(b); + }); + + it("EDGE-CASE: kayip EntityReference -> TODO + entity import NONE, throw NONE", () => { + const orphan = storedNode( + "Repository", + { + RepositoryName: "GhostRepository", + Description: "Baglantisiz repo", + EntityReference: "Phantom", + IsCached: false, + CustomQueries: [], + }, + "77777777-7777-4777-8777-777777777777", + ); + // Model/Table eklenmedi -> resolveRef null. + const { ctx } = ctxFor(orphan); + const result = emitRepository(ctx.graph.byId(orphan.id)!, ctx); + expect(result).toHaveLength(1); + const [file] = result; + expect(file.content).toContain(`// TODO: EntityReference "Phantom" could not be resolved`); + // Cozulemeyen ref -> import edilebilir sembol yok. DERLENEBILIR kalmak icin + // string token + Repository (TS2304 'Cannot find name Phantom' onlenir). + expect(file.content).toContain('@InjectRepository("Phantom")'); + expect(file.content).toContain("private readonly repo: Repository,"); + expect(file.content).not.toContain("Repository"); + expect(file.content).not.toContain(".entity"); + expect(file.surgicalMarkers).toBe(0); + }); + + it("CustomQuery tip normalizasyonu: UUID -> string, Date korunur (TS2304 onlenir)", () => { + const repo = storedNode( + "Repository", + { + RepositoryName: "EventRepository", + Description: "Olay erisimi", + EntityReference: "User", + IsCached: false, + CustomQueries: [ + { + QueryName: "findSince", + QueryType: "find", + Parameters: [{ Name: "id", Type: "UUID" }, { Name: "since", Type: "datetime" }], + ReturnType: "number", + }, + ], + }, + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + ); + const { ctx } = ctxFor(repo, USER_MODEL); + const [file] = emitRepository(ctx.graph.byId(repo.id)!, ctx); + // UUID -> string, datetime -> Date (scalarTsType); ham 'UUID'/'datetime' + // tanimsiz semboller olurdu -> nest build TS2304 ile kirilirdi. + expect(file.content).toContain("async findSince(id: string, since: Date): Promise"); + expect(file.content).not.toContain("UUID"); + expect(file.content).not.toContain("datetime"); + }); + + it("CustomQuery ReturnType entity adi -> import + sinif (User Model cozulur)", () => { + const { ctx } = ctxFor(USER_REPO, USER_MODEL); + const [file] = emitRepository(ctx.graph.byId(REPO_ID)!, ctx); + // ReturnType "User | null" -> User Model'i cozulur + entity import edilir. + expect(file.content).toContain('import { User } from "./entities/user.entity";'); + expect(file.content).toContain("Promise"); + }); + + it("CustomQuery ReturnType View adi -> VALUE import (import type NOT — TS1361 onler)", () => { + // @ViewEntity bir SINIFTIR; govde onu DEGER olarak kullanabilir (repository token, + // QueryBuilder). `import type { ActiveUsersView }` -> TS1361. Value import olmali. + const viewRepo = storedNode( + "Repository", + { + RepositoryName: "ReportRepository", + Description: "View okur", + EntityReference: "User", + IsCached: false, + CustomQueries: [ + { + QueryName: "activeSummary", + QueryType: "findMany", + Parameters: [], + ReturnType: "ActiveUsersView", + Description: "Active user summary", + }, + ], + }, + "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + ); + const viewNode = storedNode( + "View", + { + ViewName: "ActiveUsersView", + Description: "Active user summary", + Definition: "SELECT id FROM users WHERE is_active = true", + SourceTables: ["users"], + Materialized: false, + Columns: [{ Name: "id", DataType: "INT" }], + }, + "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + ); + const { ctx } = ctxFor(viewRepo, USER_MODEL, viewNode); + const [file] = emitRepository(ctx.graph.byId(viewRepo.id)!, ctx); + expect(file.content).toContain("ActiveUsersView"); + // value import (import { ... }) — import type OLMAMALI. + expect(file.content).not.toMatch(/import type\s*\{\s*ActiveUsersView/); + expect(file.content).toMatch(/import\s*\{\s*ActiveUsersView\s*\}/); + }); + + it("EDGE-CASE: bos CustomQueries -> constructor + standart CRUD (surgical NONE)", () => { + // #3: CustomQuery olmasa BILE her repository TAM CRUD tasir (findById/findAll/ + // save/remove) — bunlar GERCEK (deterministik) govdelerdir, surgical NOT. + const bare = storedNode( + "Repository", + { + RepositoryName: "BareRepository", + Description: "Sadece DI", + EntityReference: "User", + IsCached: false, + CustomQueries: [], + }, + "88888888-8888-4888-8888-888888888888", + ); + const { ctx } = ctxFor(bare, USER_MODEL); + const [file] = emitRepository(ctx.graph.byId(bare.id)!, ctx); + expect(file.content).toContain("private readonly repo: Repository,"); + // Standart CRUD daima uretilir (GERCEK govde, NOT_IMPLEMENTED yok). + expect(file.content).toContain("async findById(id: string): Promise {"); + // findById artik findOne + relations (iliskileri tek sorguda yukler). + expect(file.content).toContain("where: { id: id } as FindOptionsWhere,"); + expect(file.content).toContain("relations: this.repo.metadata.relations.map((r) => r.propertyName),"); + expect(file.content).toContain("async findAll(): Promise {"); + expect(file.content).toContain("return this.repo.find();"); + expect(file.content).toContain("async save(entity: User): Promise {"); + expect(file.content).toContain("return this.repo.save(entity);"); + expect(file.content).toContain("async remove(id: string): Promise {"); + expect(file.content).toContain("await this.repo.delete(id);"); + // CRUD gercek govde -> surgical marker / NOT_IMPLEMENTED NONE (CustomQuery yok). + expect(file.content).not.toContain("NOT_IMPLEMENTED"); + expect(file.surgicalMarkers).toBe(0); + }); + + it("#3 CRUD: kayip EntityReference -> CRUD yine uretilir (any tip + FindOptionsWhere), derlenebilir", () => { + // Kayip entity -> Repository; CRUD GERCEK govdelerle yine uretilir + // (any tip altinda da derlenir). GenericRepository diye cozulmemis ref kalmaz. + const orphan = storedNode( + "Repository", + { + RepositoryName: "GhostRepository", + Description: "Baglantisiz repo", + EntityReference: "Phantom", + IsCached: false, + CustomQueries: [], + }, + "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + ); + const { ctx } = ctxFor(orphan); + const [file] = emitRepository(ctx.graph.byId(orphan.id)!, ctx); + expect(file.content).toContain("async findById(id: string): Promise {"); + expect(file.content).toContain("where: { id: id } as FindOptionsWhere,"); + expect(file.content).toContain("async findAll(): Promise {"); + expect(file.content).toContain("async save(entity: any): Promise {"); + expect(file.content).toContain("async remove(id: string): Promise {"); + expect(file.content).toContain("import { FindOptionsWhere, Repository } from \"typeorm\";"); + expect(file.content).not.toContain("GenericRepository"); + expect(file.surgicalMarkers).toBe(0); + }); + + it("#3 CRUD: CustomQuery ayni isimde ise o CRUD metodu ATLANIR (cift metot yok)", () => { + // User kendi findById/save'ini CustomQuery olarak tanimlarsa CRUD metodu + // ATLANIR (kullanici niyeti kazanir; cift metot derlemeyi kirardi). + const repo = storedNode( + "Repository", + { + RepositoryName: "OverrideRepository", + Description: "CRUD override", + EntityReference: "User", + IsCached: false, + CustomQueries: [ + { QueryName: "findById", QueryType: "findOne", Parameters: [{ Name: "id", Type: "string" }], ReturnType: "User" }, + { QueryName: "save", QueryType: "custom", Parameters: [{ Name: "entity", Type: "User" }], ReturnType: "User" }, + ], + }, + "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + ); + const { ctx } = ctxFor(repo, USER_MODEL); + const [file] = emitRepository(ctx.graph.byId(repo.id)!, ctx); + // findById ve save CustomQuery (surgical) olarak gelir, CRUD versiyonu atlanir. + expect(file.content).not.toContain("return this.repo.findOneBy({ id: id }"); + expect(file.content).not.toContain("return this.repo.save(entity);"); + expect(file.content).toContain(`throw new Error("NOT_IMPLEMENTED: OverrideRepository.findById");`); + expect(file.content).toContain(`throw new Error("NOT_IMPLEMENTED: OverrideRepository.save");`); + // Atlanmayan CRUD (findAll/remove) GERCEK govdeyle kalir. + expect(file.content).toContain("return this.repo.find();"); + expect(file.content).toContain("await this.repo.delete(id);"); + // findById/save icin tek tanim (cakisma yok). + expect(file.content.match(/async findById/g)?.length).toBe(1); + expect(file.content.match(/async save/g)?.length).toBe(1); + }); + + it("#3 CRUD: Table entity (Model NONE) -> PK tipi kolon DataType'indan cozulur (INT id -> number)", () => { + // PK alani/tipi entity'den cozulur: id INT -> number (findById/remove param tipi). + const table = storedNode( + "Table", + { + TableName: "counters", + Description: "Sayaclar", + Columns: [ + { Name: "id", DataType: "INT", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: true }, + { Name: "value", DataType: "INT", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + ], + }, + "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + ); + const repo = storedNode( + "Repository", + { + RepositoryName: "CounterRepository", + Description: "Sayac erisimi", + EntityReference: "counters", + IsCached: false, + CustomQueries: [], + }, + "ffffffff-ffff-4fff-8fff-ffffffffffff", + ); + const { ctx } = ctxFor(repo, table); + const [file] = emitRepository(ctx.graph.byId(repo.id)!, ctx); + // INT id -> number (UUID olsaydi string'di). + expect(file.content).toContain("async findById(id: number): Promise {"); + expect(file.content).toContain("async remove(id: number): Promise {"); + }); + + it("PK kolon adi 'Id' (buyuk) -> findById entity property'siyle HIZALI 'id' kullanir (tek-kaynak)", () => { + // GERCEK BUG: graf PK'yi 'Id' (buyuk I) ile verir (to-be.json'daki tum Table'lar + // boyle). entity-synthesis property'yi tsPropName ile 'id'ye normalize eder, ama + // repository.resolvePrimaryKey ham 'Id'yi kullanirsa -> findById entity'de WITHOUT + // kolona sorgu atar (runtime EntityPropertyNotFoundError; `as FindOptionsWhere` cast + // bunu DERLEMEDE gizler). findById, entity'nin gercek property adiyla (tsPropName) + // HIZALI olmali: { id: id }, { Id: id } NOT. + const table = storedNode( + "Table", + { + TableName: "widgets", + Description: "Widget'lar", + Columns: [ + { Name: "Id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }, + ], + }, + "a1a1a1a1-1111-4111-8111-a1a1a1a1a1a1", + ); + const repo = storedNode( + "Repository", + { + RepositoryName: "WidgetRepository", + Description: "Widget erisimi", + EntityReference: "widgets", + IsCached: false, + CustomQueries: [], + }, + "b2b2b2b2-2222-4222-8222-b2b2b2b2b2b2", + ); + const { ctx } = ctxFor(repo, table); + const [file] = emitRepository(ctx.graph.byId(repo.id)!, ctx); + expect(file.content).toContain("where: { id: id } as FindOptionsWhere,"); + expect(file.content).not.toContain("{ Id: id }"); + }); + + it("PK property adi 'Id' (Model) -> findById 'id' kullanir (tek-kaynak, Model yolu)", () => { + const model = storedNode( + "Model", + { + ClassName: "Gadget", + Description: "Gadget entity", + Properties: [{ Name: "Id", Type: "uuid" }], + Methods: [], + }, + "c3c3c3c3-3333-4333-8333-c3c3c3c3c3c3", + ); + const repo = storedNode( + "Repository", + { + RepositoryName: "GadgetRepository", + Description: "Gadget erisimi", + EntityReference: "Gadget", + IsCached: false, + CustomQueries: [], + }, + "d4d4d4d4-4444-4444-8444-d4d4d4d4d4d4", + ); + const { ctx } = ctxFor(repo, model); + const [file] = emitRepository(ctx.graph.byId(repo.id)!, ctx); + expect(file.content).toContain("where: { id: id } as FindOptionsWhere,"); + expect(file.content).not.toContain("{ Id: id }"); + }); + + it("EDGE-CASE: EntityReference Table (Model NONE) -> SENTETIK entity import edilir, Repository (boot eder)", () => { + const table = storedNode( + "Table", + { + TableName: "audit_logs", + Description: "Denetim kayitlari", + Columns: [ + { + Name: "id", + DataType: "UUID", + IsPrimaryKey: true, + IsNotNull: true, + IsUnique: true, + AutoIncrement: false, + }, + ], + }, + "99999999-9999-4999-8999-999999999999", + ); + const repo = storedNode( + "Repository", + { + RepositoryName: "AuditRepository", + Description: "Denetim erisimi", + EntityReference: "audit_logs", + IsCached: false, + CustomQueries: [], + }, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + ); + const { ctx } = ctxFor(repo, table); + const [file] = emitRepository(ctx.graph.byId(repo.id)!, ctx); + // Model yok -> Table'dan SENTEZLENEN entity (AuditLog). @InjectRepository(Entity) + // /Repository/forFeature hepsi AYNI sinifa baglanir -> uygulama BOOT BOOTS. + // (string token + Repository ARTIK URETILMEZ; bootta DI hatasi verirdi.) + expect(file.content).toContain("@InjectRepository(AuditLog)"); + expect(file.content).toContain("private readonly repo: Repository,"); + expect(file.content).not.toContain("Repository"); + // Sentetik entity import'u (audit_logs -> tekil "audit-log" entity dosyasi). + expect(file.content).toContain("entities/audit-log.entity"); + expect(file.content).not.toContain("// TODO: EntityReference"); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/repository.emitter.ts b/apps/server/src/codegen/emitters/nestjs/repository.emitter.ts new file mode 100644 index 0000000..48172e9 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/repository.emitter.ts @@ -0,0 +1,338 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import { propsOf, type CodeGraph, type CodeNode } from "../../ir"; +import { + filePathFor, + pascalCase, + relativeImportPath, + importPathOf, + resolveTypeRef, + scalarTsType, + tsPropName, +} from "../../naming"; +import { ImportCollector } from "../../imports"; +import { entityClassNameForTable, synthEntityFilePath } from "./entity-synthesis"; +import { countSurgicalMarkers, notImplemented, surgicalMarker } from "../../surgical"; + +/* ──────────────────────────────────────────────────────────────────────── + * repository.emitter.ts — RepositoryNode -> /.repository.ts. + * + * CANONICAL enum.emitter.ts pattern: named `export const emitRepository`, PURE + * function, no throw, path via filePathFor, imports via ImportCollector, + * surgicalMarkers via countSurgicalMarkers, content ends with single "\n". + * + * Output: + * - @Injectable() class. (BaseClass unresolved free name — no `extends` + * generated; only TODO comment left, else TS2304 breaks compile.) + * - constructor: @InjectRepository(Entity) private readonly repo: Repository. + * Entity = EntityReference -> Model/Table node (ctx.resolveRef). + * · Model -> entity class imported. + * · Table (no Model) -> SYNTHESIZED @Entity from Table imported + * (entity-synthesis); so @InjectRepository(Entity)/Repository/ + * module.forFeature stay CONSISTENT and app BOOTS. + * · Missing ref -> string token + Repository (compilable, TODO). + * - STANDARD CRUD (#3): every repository carries FULL CRUD — findById/findAll/save/ + * remove. These are NOT surgical; REAL (deterministic) bodies delegate to + * injected TypeORM Repository (no NOT_IMPLEMENTED): + * findById(id): repo.findOneBy({ : id }) -> Entity | null + * findAll(): repo.find() -> Entity[] + * save(entity): repo.save(entity) -> Entity + * remove(id): repo.delete(id) (void) + * PK field/type resolved from entity (Model "id" / Table pickPrimaryKey). If + * CustomQuery has same name CRUD method SKIPPED (user intent wins; + * duplicate method breaks compile). Missing entity (Repository) -> CRUD + * still generated (any type, compilable; pk type falls back to string). + * - CustomQueries: each async method signature (Parameters + ReturnType) + + * surgical marker + NOT_IMPLEMENTED body. Sorted by name (determinism). + * Param/Return types normalized via scalarTsType + resolveTypeRef + * (UUID->string; User -> import + class), else TS2304. + * Surgical marker gets GUIDANCE note for synthesized @ManyToOne/@OneToMany + * relations (M2): "fetch via join/relations, avoid N+1"; Surgical AI loads + * related data in one query when filling body. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Fixed GUIDANCE note for Surgical AI to use synthesized entity relations + * (M2 @ManyToOne/@OneToMany) efficiently. Entities emitted eager:false; + * when related data needed use QueryBuilder.leftJoinAndSelect or + * find({ relations: [...] }) in ONE query — lazy access in loop causes + * N+1 explosion. Deterministic (fixed text). */ +const RELATION_GUIDANCE = + "GUIDANCE: fetch related data in a SINGLE query via join/relations (leftJoinAndSelect or find({ relations })); avoid N+1 by not relying on lazy access inside a loop."; + +export const emitRepository: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = propsOf<"Repository">(node); + const className = pascalCase(node.name); + const filePath = filePathFor(node, ctx.graph); + + const imports = new ImportCollector(); + imports.add("Injectable", "@nestjs/common"); + imports.add("InjectRepository", "@nestjs/typeorm"); + imports.add("Repository", "typeorm"); + + // ── EntityReference resolution (Model or Table) ──────────────────────── + // @InjectRepository() + private readonly repo: Repository<>. + // - Resolved Model -> entity class + import (type and value same symbol). + // - Resolved Table (no Model) -> SYNTHESIZED @Entity from Table + + // import. Synthetic entity produced by entity-synthesis emitter; + // name via entityClassNameForTable SINGLE SOURCE -> forFeature/InjectRepository + // /Repository all bind SAME class -> app BOOTS. + // - Unresolved ref -> STRING token to stay COMPILABLE: + // @InjectRepository("rawRef") + Repository (prevents TS2304). + const entityRefName = props.EntityReference; + const entityNode = entityRefName + ? ctx.graph.resolveRef(["Model", "Table"], entityRefName) + : null; + + const isModelEntity = entityNode !== null && entityNode.kindOf() === "Model"; + const isTableEntity = entityNode !== null && entityNode.kindOf() === "Table"; + const missingEntity = entityRefName.length > 0 && entityNode === null; + + // Repository<...> type arg and @InjectRepository(...) value arg. + let entityType: string; + let injectArg: string; + if (isModelEntity) { + entityType = pascalCase(entityNode!.name); + injectArg = entityType; + const toPath = filePathFor(entityNode!, ctx.graph); + imports.add(entityType, importPathOf(relativeImportPath(filePath, toPath))); + } else if (isTableEntity) { + // No Model -> synthesized entity from Table (same name/path as entity-synthesis). + entityType = entityClassNameForTable(entityNode!); + injectArg = entityType; + imports.add( + entityType, + importPathOf(relativeImportPath(filePath, synthEntityFilePath(entityNode!, ctx.graph))), + ); + } else { + // Missing ref -> no importable symbol. String token + any. + entityType = "any"; + injectArg = JSON.stringify(entityRefName); + } + + const lines: string[] = []; + + if (props.Description) { + lines.push(`/** ${props.Description} */`); + } + // BaseClass: unresolved free name -> no `extends` generated (prevents TS2304). + if (props.BaseClass && props.BaseClass.length > 0) { + lines.push( + `// TODO: BaseClass "${props.BaseClass}" — unresolved base class; \`extends\` was not generated (add it manually).`, + ); + } + lines.push("@Injectable()"); + lines.push(`export class ${className} {`); + + // ── constructor + DI ──────────────────────────────────────────────────── + if (missingEntity) { + lines.push(` // TODO: EntityReference "${entityRefName}" could not be resolved (no Model/Table found).`); + } + lines.push(" constructor("); + lines.push(` @InjectRepository(${injectArg})`); + lines.push(` private readonly repo: Repository<${entityType}>,`); + lines.push(" ) {}"); + + // ── STANDARD CRUD (#3): findById/findAll/save/remove ──────────────────── + // REAL (deterministic) bodies — delegate to injected TypeORM Repository; + // not surgical, not NOT_IMPLEMENTED. If CustomQuery has same name that CRUD + // method SKIPPED (user intent wins + duplicate method breaks compile). PK field + // name/type from entity (missing entity -> "id"/string + any). + const customNames = new Set((props.CustomQueries ?? []).map((q) => q.QueryName)); + const pk = resolvePrimaryKey(entityNode); + const crud = renderCrudMethods(entityType, pk, customNames); + if (crud.usesFindOptionsWhere) imports.add("FindOptionsWhere", "typeorm"); + for (const ml of crud.lines) lines.push(ml); + + // ── CustomQueries -> async method + surgical body ─────────────────────── + const queries = [...(props.CustomQueries ?? [])].sort((a, b) => + a.QueryName < b.QueryName ? -1 : a.QueryName > b.QueryName ? 1 : 0, + ); + + for (const q of queries) { + const methodName = q.QueryName; + const params = (q.Parameters ?? []) + .map((p) => `${p.Name}: ${resolveQueryType(p.Type, ctx.graph, filePath, imports)}`) + .join(", "); + const returnType = wrapPromise(resolveQueryType(q.ReturnType, ctx.graph, filePath, imports)); + + lines.push(""); + lines.push(` async ${methodName}(${params}): ${returnType} {`); + // Work description + relation/N+1 guidance (synthesized @ManyToOne/ + // @OneToMany relations eager:false; Surgical AI fetches via join in one query). + const description = q.Description + ? `${q.Description}\n${RELATION_GUIDANCE}` + : RELATION_GUIDANCE; + const marker = surgicalMarker({ + nodeId: node.id, + member: methodName, + description, + deps: ["repo"], + }); + for (const ml of marker.split("\n")) lines.push(` ${ml}`); + lines.push(` ${notImplemented(className, methodName)}`); + lines.push(" }"); + } + + lines.push("}"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: filePath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/* ── STANDARD CRUD (#3) ──────────────────────────────────────────────────── + * Every repository carries FULL CRUD delegating to injected TypeORM + * Repository. Bodies REAL + deterministic (no NOT_IMPLEMENTED, no + * surgical) — TypeORM API suffices, no algorithm needed: + * findById(id): repo.findOneBy({ : id }) -> Entity | null + * findAll(): repo.find() -> Entity[] + * save(entity): repo.save(entity) -> Entity + * remove(id): repo.delete(id) (void) + * If CustomQuery has same name CRUD method SKIPPED (duplicate breaks compile; + * user intent wins). ──────────────────────────────────────────────── */ + +/** Entity primary-key field name + TS type. Unresolved entity (Repository + * ) -> { name:"id", tsType:"string" } (compilable default). */ +interface PrimaryKey { + /** PK field name (entity property). */ + name: string; + /** PK TS type (findById/remove param type). */ + tsType: string; +} + +/** Resolve PK field name + TS type from entity node (Model or Table). + * - Model: Property named "id"; else first Property; else "id"/string. + * - Table: column named "id"; else IsPrimaryKey column; else first column. + * Missing entity (null) -> "id"/string. Pure + deterministic (DataType normalize). */ +function resolvePrimaryKey(entityNode: CodeNode | null): PrimaryKey { + if (!entityNode) return { name: "id", tsType: "string" }; + + if (entityNode.kindOf() === "Model") { + const properties = propsOf<"Model">(entityNode).Properties ?? []; + const byId = properties.find((p) => p.Name.toLowerCase() === "id"); + const chosen = byId ?? properties[0]; + // SINGLE SOURCE: entity property name = tsPropName(name) (same as model.emitter/entity-synthesis). + // Raw 'Id' would bind findById to non-existent column on entity. + if (chosen) return { name: tsPropName(chosen.Name), tsType: scalarTsType(chosen.Type) }; + return { name: "id", tsType: "string" }; + } + + if (entityNode.kindOf() === "Table") { + const columns = propsOf<"Table">(entityNode).Columns ?? []; + const byId = columns.find((c) => c.Name.toLowerCase() === "id"); + const flagged = columns.find((c) => c.IsPrimaryKey === true); + const chosen = byId ?? flagged ?? columns[0]; + // SINGLE SOURCE: entity property name = tsPropName(col.Name) (same as entity-synthesis, + // e.g. "Id" -> "id", "CustomerId" -> "customerId"). Else findById queries non-existent + // column (as-cast hides, runtime fails). + if (chosen) return { name: tsPropName(chosen.Name), tsType: scalarTsType(chosen.DataType) }; + return { name: "id", tsType: "string" }; + } + + return { name: "id", tsType: "string" }; +} + +/** renderCrudMethods output: generated lines + whether FindOptionsWhere import needed. */ +interface CrudRender { + /** indented CRUD method lines (blank line prefix per method). */ + lines: string[]; + /** Import FindOptionsWhere when findById generated (strict-safe cast). */ + usesFindOptionsWhere: boolean; +} + +/** Generate standard CRUD method lines (indented). CRUD method SKIPPED when + * name collides with customNames. When entityType is "any" (missing entity) + * bodies stay compilable (repo: Repository). findById where arg cast to + * FindOptionsWhere to compile under strict (plain object literal not + * assignable to FindOptionsWhere; PK field dynamic). */ +function renderCrudMethods( + entityType: string, + pk: PrimaryKey, + customNames: Set, +): CrudRender { + const idType = pk.tsType.length > 0 ? pk.tsType : "string"; + // Write PK field name safely as object literal key (stringify if needed). + const pkKey = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(pk.name) ? pk.name : JSON.stringify(pk.name); + + const blocks: Array<{ name: string; lines: string[] }> = [ + { + name: "findById", + // findById loads entity's DIRECT relations in one query — + // relation names from runtime entity-metadata (no cross-emitter calc; synthetic + // entity included). Caller can safely access `entity.` (audit + // #12/#13: accessing unloaded relation after findById undefined/crash). No relations -> + // relations=[] -> no extra join. eager:false intent preserved (single-entity + // aggregate fetch; not eager-on-every-list). + lines: [ + ` async findById(id: ${idType}): Promise<${entityType} | null> {`, + ` return this.repo.findOne({`, + ` where: { ${pkKey}: id } as FindOptionsWhere<${entityType}>,`, + ` relations: this.repo.metadata.relations.map((r) => r.propertyName),`, + ` });`, + " }", + ], + }, + { + name: "findAll", + lines: [ + ` async findAll(): Promise<${entityType}[]> {`, + " return this.repo.find();", + " }", + ], + }, + { + name: "save", + lines: [ + ` async save(entity: ${entityType}): Promise<${entityType}> {`, + " return this.repo.save(entity);", + " }", + ], + }, + { + name: "remove", + lines: [ + ` async remove(id: ${idType}): Promise {`, + " await this.repo.delete(id);", + " }", + ], + }, + ]; + + const out: string[] = []; + let usesFindOptionsWhere = false; + for (const b of blocks) { + if (customNames.has(b.name)) continue; // user CustomQuery wins + if (b.name === "findById") usesFindOptionsWhere = true; + out.push(""); + out.push(...b.lines); + } + return { lines: out, usesFindOptionsWhere }; +} + +/** Wrap ReturnType in Promise (unchanged if already Promise<...>). Async + * method always returns Promise; single rule for determinism. */ +function wrapPromise(returnType: string): string { + const t = returnType.trim(); + if (t.length === 0) return "Promise"; + if (/^Promise\s*`; +} + +/** Convert CustomQuery param/return type string to VALID TS: + * scalarTsType (UUID->string, int->number ...) + entity/DTO name resolution + * (resolveTypeRef -> import + class). Unresolved free name passes through. + * Else undefined symbols like "User"/"UUID" break compile with TS2304. */ +function resolveQueryType( + rawType: string, + graph: CodeGraph, + fromFile: string, + imports: ImportCollector, +): string { + return resolveTypeRef(rawType, graph, fromFile, imports); +} diff --git a/apps/server/src/codegen/emitters/nestjs/scaffold.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/scaffold.emitter.spec.ts new file mode 100644 index 0000000..0610786 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/scaffold.emitter.spec.ts @@ -0,0 +1,736 @@ +import { describe, it, expect } from "vitest"; +import { emitScaffoldProject, fillDepsPackageJson } from "./scaffold.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; +import type { NodeKind } from "../../../nodes/schemas"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +let seq = 0; +function uuid(): string { + seq += 1; + const tail = String(seq).padStart(12, "0"); + return `00000000-0000-4000-8000-${tail}`; +} + +function node(type: NodeKind, properties: Record, id = uuid()): StoredNode { + return { + id, + type, + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function edge(kind: StoredEdge["kind"], sourceNodeId: string, targetNodeId: string): StoredEdge { + return { + id: uuid(), + projectId: "00000000-0000-4000-8000-000000000000", + sourceNodeId, + targetNodeId, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: false }, + }; +} + +function ctxFor(nodes: StoredNode[], edges: StoredEdge[] = []): EmitterContext { + return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; +} + +function fileByPath(files: ReturnType, path: string) { + const f = files.find((x) => x.path === path); + if (!f) throw new Error(`expected file ${path}`); + return f; +} + +/* ── Gercekci kucuk graph ──────────────────────────────────────────────── */ +function richGraph() { + const usersModule = node("Module", { + ModuleName: "UsersModule", + Description: "User management", + StrictBoundaries: true, + ExposedServices: ["UsersService"], + Dependencies: [], + }); + const usersService = node("Service", { + ServiceName: "UsersService", + Description: "User is mantigi", + IsTransactionScoped: false, + Methods: [{ MethodName: "findAll", ReturnType: "User[]" }], + Dependencies: [], + }); + const usersController = node("Controller", { + ControllerName: "UsersController", + Description: "User uclari", + BaseRoute: "users", + Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: false }], + }); + // Module'e baglanamayan loose node'lar (moduleOf === null). + const healthController = node("Controller", { + ControllerName: "HealthController", + Description: "Health check", + BaseRoute: "health", + Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: false }], + }); + const clockService = node("Service", { + ServiceName: "ClockService", + Description: "Zaman servisi", + IsTransactionScoped: false, + Methods: [{ MethodName: "now", ReturnType: "Date" }], + Dependencies: [], + }); + // EnvironmentVariable node'lari. + const dbUrl = node("EnvironmentVariable", { + Key: "DATABASE_URL", + Description: "Postgres connection string", + DataType: "String", + IsSecret: false, + Environment: ["Dev", "Prod"], + DefaultValue: "postgres://user:password@localhost:5432/app", + IsRequired: true, + }); + const jwtSecret = node("EnvironmentVariable", { + Key: "JWT_SECRET", + Description: "JWT imzalama anahtari", + DataType: "String", + IsSecret: true, + Environment: ["Dev", "Staging", "Prod"], + IsRequired: true, + }); + const port = node("EnvironmentVariable", { + Key: "PORT", + Description: "HTTP portu", + DataType: "Number", + IsSecret: false, + Environment: ["Dev"], + DefaultValue: "3000", + IsRequired: false, + }); + + const edges = [ + // Controller -> Service yalniz CALLS edge'inden gelir (modul bagi buradan). + edge("CALLS", usersController.id, usersService.id), + ]; + + return { + nodes: [usersModule, usersService, usersController, healthController, clockService, dbUrl, jwtSecret, port], + edges, + }; +} + +describe("emitScaffoldProject (graph-farkinda scaffold)", () => { + it("proje dosyalarini uretir (CoreModule + shared filter + data-source + test/CI iskeleti dahil)", () => { + const { nodes, edges } = richGraph(); + const files = emitScaffoldProject(ctxFor(nodes, edges)); + // richGraph EnvironmentVariable node'lari icerir -> src/config/configuration.ts + // de uretilir (ENV -> tipli config). env.validation.ts (Joi) DAIMA uretilir. + // H1-H6: core.module + shared/filters + data-source + tsconfig.build + jest-e2e + // + .gitignore + test/app.e2e-spec. .env.example KOKTE (H4). + expect(files.map((f) => f.path).sort()).toEqual( + [ + ".env.example", + ".gitignore", + "README.md", + "jest-e2e.json", + "nest-cli.json", + "package.json", + "src/app.module.ts", + "src/config/configuration.ts", + "src/config/env.validation.ts", + "src/core/core.module.ts", + "src/data-source.ts", + "src/main.ts", + "src/shared/filters/all-exceptions.filter.ts", + "test/app.e2e-spec.ts", + "tsconfig.build.json", + "tsconfig.json", + ].sort(), + ); + }); + + it("app.module.ts — INCE: CoreModule + feature modulleri (root forRoot/register CoreModule'de)", () => { + const { nodes, edges } = richGraph(); + const files = emitScaffoldProject(ctxFor(nodes, edges)); + const app = fileByPath(files, "src/app.module.ts"); + + // Sirali import bloku: paketler once, goreli sonra. + expect(app.content).toContain('import { Module } from "@nestjs/common";'); + // H3: app.module artik CoreModule'u import eder (tum root altyapi orada). + expect(app.content).toContain('import { CoreModule } from "./core/core.module";'); + expect(app.content).toContain(" CoreModule,"); + // Her feature -> bir sentezlenmis .module.ts. + expect(app.content).toContain('import { UsersModule } from "./users/users.module";'); + expect(app.content).toContain('import { HealthModule } from "./health/health.module";'); + expect(app.content).toContain('import { ClockModule } from "./clock/clock.module";'); + + // Feature modulleri @Module.imports'a girer (slug'a gore sirali). + expect(app.content).toContain(" UsersModule,"); + expect(app.content).toContain(" HealthModule,"); + expect(app.content).toContain(" ClockModule,"); + // H3: app.module ROOT forRoot/register ICERMEZ (hepsi CoreModule'de). + expect(app.content).not.toContain("TypeOrmModule.forRootAsync"); + expect(app.content).not.toContain("ConfigModule.forRoot"); + // app.module HAM controller/provider icermez (hepsi feature modullerinde). + expect(app.content).not.toContain("controllers:"); + expect(app.content).not.toContain("providers:"); + expect(app.content).not.toContain("UsersController"); + expect(app.content).not.toContain("UsersService"); + expect(app.content.endsWith("export class AppModule {}\n")).toBe(true); + + // CoreModule TUM root altyapiyi tasir (H1/H2/H3). + const core = fileByPath(files, "src/core/core.module.ts"); + expect(core.content).toContain("ConfigModule.forRoot({ isGlobal: true, load: [configuration], validationSchema })"); + expect(core.content).toContain("TypeOrmModule.forRootAsync({"); + expect(core.content).toContain('type: "postgres" as const,'); + expect(core.content).toContain('config.getOrThrow("DATABASE_URL")'); + // #10: snake_case naming strategy on the runtime connection (same as CLI). + expect(core.content).toContain('import { SnakeNamingStrategy } from "typeorm-naming-strategies";'); + expect(core.content).toContain("namingStrategy: new SnakeNamingStrategy(),"); + // H2: Pino logger CoreModule'de kurulur. + expect(core.content).toContain('import { LoggerModule } from "nestjs-pino";'); + expect(core.content).toContain("LoggerModule.forRoot({"); + // H1: global exception filter APP_FILTER ile baglanir. + expect(core.content).toContain('import { APP_FILTER } from "@nestjs/core";'); + expect(core.content).toContain("provide: APP_FILTER, useClass: AllExceptionsFilter"); + expect(core.content.endsWith("export class CoreModule {}\n")).toBe(true); + }); + + it("all-exceptions.filter.ts — tutarli zarf + generic 500'de sizinti yok (H1)", () => { + const files = emitScaffoldProject(ctxFor(richGraph().nodes, richGraph().edges)); + const filter = fileByPath(files, "src/shared/filters/all-exceptions.filter.ts"); + expect(filter.content).toContain("@Catch()"); + expect(filter.content).toContain("implements ExceptionFilter"); + // Tutarli zarf alanlari. + for (const field of ["statusCode", "error", "message", "requestId", "timestamp"]) { + expect(filter.content).toContain(field); + } + // HttpException status'u korunur; generic -> 500 + jenerik mesaj. + expect(filter.content).toContain("exception instanceof HttpException"); + expect(filter.content).toContain("HttpStatus.INTERNAL_SERVER_ERROR"); + expect(filter.language).toBe("typescript"); + }); + + it("data-source.ts + db:migrate script (H5)", () => { + const files = emitScaffoldProject(ctxFor(richGraph().nodes, richGraph().edges)); + const ds = fileByPath(files, "src/data-source.ts"); + expect(ds.content).toContain("new DataSource({"); + expect(ds.content).toContain('migrations: ["dist/migrations/*.js"]'); + expect(ds.content).toContain("synchronize: false"); + // M1: data-source SADECE type/url/synchronize tasir — havuz/retry runtime'a + // (CoreModule forRootAsync) ait; CLI DataSource bunlardan OZGUN kalir. + expect(ds.content).not.toContain("extra:"); + expect(ds.content).not.toContain("retryAttempts"); + // #2: data-source imports dotenv to load .env in the CLI context — dotenv MUST + // therefore be an explicit dependency (otherwise compile fails: TS2307). + expect(ds.content).toContain('import { config as loadEnv } from "dotenv";'); + // #10: same SnakeNamingStrategy as the runtime, so the CLI sees the same + // snake_case schema the migrations create. + expect(ds.content).toContain('import { SnakeNamingStrategy } from "typeorm-naming-strategies";'); + expect(ds.content).toContain("namingStrategy: new SnakeNamingStrategy(),"); + const pkg = fileByPath(files, "package.json"); + expect(pkg.content).toContain('"db:migrate": "typeorm migration:run -d dist/data-source.js"'); + // #2 + #10: the new direct imports are declared as dependencies. + expect(pkg.content).toContain('"dotenv":'); + expect(pkg.content).toContain('"typeorm-naming-strategies":'); + }); + + it("test/CI iskeleti: tsconfig.build + jest config + e2e smoke + .gitignore (H6)", () => { + const files = emitScaffoldProject(ctxFor(richGraph().nodes, richGraph().edges)); + const gitignore = fileByPath(files, ".gitignore"); + for (const ignore of ["node_modules", "dist", ".env"]) { + expect(gitignore.content).toContain(ignore); + } + const tsBuild = fileByPath(files, "tsconfig.build.json"); + expect(tsBuild.content).toContain('"extends": "./tsconfig.json"'); + const e2e = fileByPath(files, "test/app.e2e-spec.ts"); + expect(e2e.content).toContain("Test.createTestingModule"); + expect(e2e.content).toContain("import { AppModule }"); + const pkg = fileByPath(files, "package.json"); + expect(pkg.content).toContain('"jest"'); + expect(pkg.content).toContain('"@nestjs/testing"'); + }); + + it("tsconfig.json: TS7-uyumlu — baseUrl NONE (kaldirildi), types acik [node, jest], lib ES2022", () => { + const files = emitScaffoldProject(ctxFor(richGraph().nodes, richGraph().edges)); + const tsconfig = fileByPath(files, "tsconfig.json"); + // baseUrl oluydu (import'lar relative) + TS 7.0 (tsgo) onu reddeder (TS5102) → OLMAMALI. + expect(tsconfig.content).not.toContain("baseUrl"); + // @types global cozumu explicit (baseUrl gidince tsgo otomatik taramayi kaybeder). + expect(tsconfig.content).toContain('"types": ["node", "jest"]'); + // DOM-collision fix'i korunur. + expect(tsconfig.content).toContain('"lib": ["ES2022"]'); + // Gecerli JSON kalmali. + expect(() => JSON.parse(tsconfig.content)).not.toThrow(); + }); + + it(".env.example — KOKTE (H4); env node'larindan; secret ASLA gercek deger almaz", () => { + const { nodes, edges } = richGraph(); + const files = emitScaffoldProject(ctxFor(nodes, edges)); + const env = fileByPath(files, ".env.example"); + + // DefaultValue olan public degisken degerini alir. + expect(env.content).toContain("DATABASE_URL=postgres://user:password@localhost:5432/app"); + expect(env.content).toContain("PORT=3000"); + // Secret placeholder — gercek deger yok. + expect(env.content).toContain("JWT_SECRET="); + // Aciklama + required/optional meta satiri. + expect(env.content).toContain("# Postgres connection string (Dev/Prod; required)"); + expect(env.content).toContain("# HTTP portu (Dev; optional)"); + // M1: DB havuz/timeout/retry ornek degerleri. + expect(env.content).toContain("DB_POOL_MAX=10"); + expect(env.content).toContain("DB_CONNECTION_TIMEOUT_MS=10000"); + // L2: CORS + body-limit (CORS bos = kapali). + expect(env.content).toContain("CORS_ORIGIN="); + expect(env.content).toContain("BODY_LIMIT=1mb"); + expect(env.language).toBe("env"); + }); + + it("README.md — surgical doldurma talimati + uretim notu", () => { + const files = emitScaffoldProject(ctxFor(richGraph().nodes, richGraph().edges)); + const readme = fileByPath(files, "README.md"); + expect(readme.content).toContain("@solarch:surgical"); + expect(readme.content).toContain("NOT_IMPLEMENTED"); + expect(readme.content).toContain("Filling in surgical areas"); + expect(readme.language).toBe("markdown"); + }); + + it("#12 README — describes only really-emitted folders (shared guards/decorators conditional)", () => { + // A graph with NO auth/roles endpoints: shared/guards + shared/decorators are + // NOT emitted. README must not claim they unconditionally exist; it states + // they are generated only when an endpoint requires auth / declares roles. + const noAuth = node("Controller", { + ControllerName: "PublicController", + Description: "public", + BaseRoute: "public", + Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: false }], + }); + const files = emitScaffoldProject(ctxFor([noAuth], [])); + // Guard/decorator stubs are NOT emitted for this graph. + expect(files.find((f) => f.path === "src/shared/guards/auth.guard.ts")).toBeUndefined(); + expect(files.find((f) => f.path === "src/shared/decorators/roles.decorator.ts")).toBeUndefined(); + // current-user.decorator is also conditional (RequiresAuth / login endpoint). + expect( + files.find((f) => f.path === "src/shared/decorators/current-user.decorator.ts"), + ).toBeUndefined(); + + const readme = fileByPath(files, "README.md"); + // filters/ is always present -> always described. + expect(readme.content).toContain("filters/all-exceptions.filter.ts"); + // guards/decorators are described as CONDITIONAL ("when ...") — kaydirma-toleransli. + expect(readme.content).toMatch(/generated when an endpoint\s+requires\s+authentication/); + expect(readme.content).toMatch(/only when an endpoint\s+declares\s+required roles/); + // README mentions the (conditional) current-user decorator (Finding #8). + expect(readme.content).toContain("current-user.decorator.ts"); + + // With auth endpoints, ALL three stubs ARE emitted (guard + roles + current-user). + const withAuth = node("Controller", { + ControllerName: "SecureController", + Description: "secure", + BaseRoute: "secure", + Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: true, RequiredRoles: ["admin"] }], + }); + const authFiles = emitScaffoldProject(ctxFor([withAuth], [])); + expect(authFiles.find((f) => f.path === "src/shared/guards/auth.guard.ts")).toBeDefined(); + expect(authFiles.find((f) => f.path === "src/shared/decorators/roles.decorator.ts")).toBeDefined(); + expect( + authFiles.find((f) => f.path === "src/shared/decorators/current-user.decorator.ts"), + ).toBeDefined(); + }); + + /* ── RBAC WIRE (#39): usesRoles -> GERCEK RolesGuard emit edilir ────────── + * Eskiden yalniz roles.decorator (metadata yazan) uretiliyordu; onu OKUYAN guard + * yoktu -> @Roles oluydu. Artik roles.guard.ts de uretilir: Reflector ile ROLES_KEY + * metadata'sini okuyup request.user.role'u gerekli rollere gore enforce eder. */ + it("usesRoles -> roles.guard.ts (RolesGuard: Reflector + ROLES_KEY metadata okur)", () => { + const withRoles = node("Controller", { + ControllerName: "SecureController", + Description: "secure", + BaseRoute: "secure", + Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: true, RequiredRoles: ["admin"] }], + }); + const files = emitScaffoldProject(ctxFor([withRoles], [])); + const guard = files.find((f) => f.path === "src/shared/guards/roles.guard.ts"); + expect(guard).toBeDefined(); + expect(guard!.content).toContain("export class RolesGuard"); + expect(guard!.content).toContain("Reflector"); + expect(guard!.content).toContain("ROLES_KEY"); + expect(guard!.content).toContain("getAllAndOverride"); + // Rol gerekmiyorsa gecer; aksi halde user.role gerekli rollerden biri olmali. + // #58: rol karsilastirmasi CASE-INSENSITIVE (graf "ADMIN" ↔ enum "admin" casing + // uyusmazligina dayanikli; RBAC sessizce kirilmasin). + expect(guard!.content).toContain("toLowerCase()"); + expect(guard!.content).toMatch(/required\.some\(/); + }); + + /* ── CAPABILITY-LAYER AUTH (#37/#38): AuthGuard GERCEK JWT dogrular ─────── + * Eskiden AuthGuard `return true` placeholder'di (surgical, AI dolduruyor -> sahte + * JWT). Artik deterministik GERCEK guard: Bearer token'i JWT_SECRET ile dogrular, + * request.user'a cozulen claim'leri koyar (@CurrentUser + RolesGuard okur), 401 atar. + * Artik surgical NOT -> fill dokunmaz; auth strateji = JWT (env JWT_SECRET kullanilir). */ + it("AuthGuard GERCEK JWT dogrular (deterministik, surgical degil) + jsonwebtoken dep", () => { + const withAuth = node("Controller", { + ControllerName: "SecureController", + Description: "secure", + BaseRoute: "secure", + Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: true, RequiredRoles: [] }], + }); + const files = emitScaffoldProject(ctxFor([withAuth], [])); + const guard = files.find((f) => f.path === "src/shared/guards/auth.guard.ts"); + expect(guard).toBeDefined(); + // Token dogrulama tek-kaynak verifyAccessToken'a delege (auth-token.ts; JWT_SECRET orada). + expect(guard!.content).toContain("verifyAccessToken"); + expect(guard!.content).toContain("../auth/auth-token"); + expect(guard!.content).toContain("request.user ="); + expect(guard!.content).toContain("UnauthorizedException"); + // Placeholder gitti + artik surgical NOT (fill dokunmaz) + cast yok. + // (Gercek guard DA dogrulamadan AFTER return true yapar — kosulsuz degil.) + expect(guard!.content).not.toContain("@solarch:surgical"); + expect(guard!.surgicalMarkers).toBe(0); + expect(guard!.content).not.toContain("as any"); + // package.json auth deps (yalniz auth kullanilinca). + const pkg = files.find((f) => f.path === "package.json"); + expect(pkg!.content).toContain('"jsonwebtoken"'); + expect(pkg!.content).toContain('"@types/jsonwebtoken"'); + }); + + /* ── AUTH HELPER'LARI: password (bcrypt) + token (tek-kaynak) ──────────── + * Login/Register fill'i icin deterministik primitive'ler: comparePassword/ + * hashPassword (duz-metin karsilastirma yerine) + signAccessToken/verifyAccessToken + * (sahte 'token' yerine; AuthGuard ile TEK SOURCE). usesAuth iken uretilir. */ + it("auth helper'lari uretilir: password.ts (bcrypt) + auth-token.ts (sign/verify)", () => { + const withAuth = node("Controller", { + ControllerName: "SecureController", + Description: "secure", + BaseRoute: "secure", + Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: true, RequiredRoles: [] }], + }); + const files = emitScaffoldProject(ctxFor([withAuth], [])); + const pw = files.find((f) => f.path === "src/shared/auth/password.ts"); + expect(pw, "password.ts not generated").toBeDefined(); + expect(pw!.content).toContain("bcryptjs"); + expect(pw!.content).toContain("export function hashPassword"); + expect(pw!.content).toContain("export function comparePassword"); + const tok = files.find((f) => f.path === "src/shared/auth/auth-token.ts"); + expect(tok, "auth-token.ts not generated").toBeDefined(); + expect(tok!.content).toContain("export function signAccessToken"); + expect(tok!.content).toContain("export function verifyAccessToken"); + expect(tok!.content).toContain("JWT_SECRET"); + // AuthGuard artik tek-kaynak verifyAccessToken'i kullanir (inline verify degil). + const guard = files.find((f) => f.path === "src/shared/guards/auth.guard.ts")!; + expect(guard.content).toContain("verifyAccessToken"); + expect(guard.content).toContain("../auth/auth-token"); + // bcryptjs dep (auth kullanilinca). + const pkg = files.find((f) => f.path === "package.json")!; + expect(pkg.content).toContain('"bcryptjs"'); + expect(pkg.content).toContain('"@types/bcryptjs"'); + }); + + it("auth NONEKEN jsonwebtoken dep EKLENMEZ (kosullu)", () => { + const noAuth = node("Controller", { + ControllerName: "PublicController", + Description: "public", + BaseRoute: "public", + Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: false, RequiredRoles: [] }], + }); + const pkg = emitScaffoldProject(ctxFor([noAuth], [])).find((f) => f.path === "package.json"); + expect(pkg!.content).not.toContain("jsonwebtoken"); + }); + + it("package.json — gerekli bagimliliklar pinlenmis", () => { + const files = emitScaffoldProject(ctxFor(richGraph().nodes)); + const pkg = fileByPath(files, "package.json"); + for (const dep of [ + "@nestjs/common", + "@nestjs/config", + "@nestjs/core", + "@nestjs/platform-express", + "@nestjs/typeorm", + "class-validator", + "class-transformer", + "joi", + "typeorm", + "pg", + "reflect-metadata", + // L2: helmet + express (main.ts body-limit/CORS) daima. + "helmet", + "express", + // #2 dotenv (data-source CLI) + #10 SnakeNamingStrategy (runtime + CLI). + "dotenv", + "typeorm-naming-strategies", + ]) { + expect(pkg.content).toContain(`"${dep}"`); + } + // L4: paket-yoneticisi pin (README pnpm komutlari ile tutarli; Corepack okur). + expect(pkg.content).toContain('"packageManager": "pnpm@10.0.0"'); + expect(pkg.language).toBe("json"); + }); + + it("main.ts — NestFactory (bufferLogs + Pino logger) + ValidationPipe + ConfigService port", () => { + const files = emitScaffoldProject(ctxFor(richGraph().nodes)); + const main = fileByPath(files, "src/main.ts"); + // H2: bufferLogs + Pino logger devralir (fail-fast abortOnError varsayilan). + expect(main.content).toContain("NestFactory.create(AppModule, { bufferLogs: true })"); + expect(main.content).toContain('import { Logger } from "nestjs-pino";'); + expect(main.content).toContain("app.useLogger(app.get(Logger))"); + // #66: whitelist + forbidNonWhitelisted -> bilinmeyen body alanlari 400 ile reddedilir + // (sessizce strip degil); transform -> DTO tip donusumu. + expect(main.content).toContain("whitelist: true"); + expect(main.content).toContain("forbidNonWhitelisted: true"); + expect(main.content).toContain("transform: true"); + // Port ConfigService'ten okunur (env dogrulamasindan sonra). + expect(main.content).toContain("const config = app.get(ConfigService);"); + expect(main.content).toContain('config.get("PORT")'); + }); + + it("main.ts — L1 graceful shutdown + L2 helmet/CORS/body-limit (ConfigService-gated)", () => { + const files = emitScaffoldProject(ctxFor(richGraph().nodes)); + const main = fileByPath(files, "src/main.ts"); + // L1: SIGTERM'de lifecycle hook'lari (TypeORM havuzu temiz kapanir). + expect(main.content).toContain("app.enableShutdownHooks();"); + // L2: helmet guvenlik basliklari. + expect(main.content).toContain('import helmet from "helmet";'); + expect(main.content).toContain("app.use(helmet());"); + // L2: govde siniri (ConfigService-gated, makul default). + expect(main.content).toContain('import { json, urlencoded } from "express";'); + expect(main.content).toContain('config.get("BODY_LIMIT") ?? "1mb"'); + expect(main.content).toContain("app.use(json({ limit: bodyLimit }));"); + // L2: CORS yalniz CORS_ORIGIN tanimliysa acilir (yoksa kapali — prod-guvenli). + expect(main.content).toContain('config.get("CORS_ORIGIN")'); + expect(main.content).toContain("app.enableCors({"); + }); + + /* ── Self-documenting app: @nestjs/swagger document + Scalar /docs ──────── + * Generated main.ts builds an OpenAPI document from the swagger decorators and + * serves an interactive Scalar reference at /docs. A SEPARATE DOCS_CORS_ORIGIN + * flag lets the Solarch app issue "try it" requests at the running server + * WITHOUT loosening the prod CORS_ORIGIN allowlist. */ + it("main.ts — SwaggerModule document + Scalar /docs + separate DOCS_CORS_ORIGIN", () => { + const files = emitScaffoldProject(ctxFor(richGraph().nodes)); + const main = fileByPath(files, "src/main.ts"); + // OpenAPI document built from the @nestjs/swagger decorators. + expect(main.content).toContain('from "@nestjs/swagger";'); + expect(main.content).toContain("SwaggerModule.createDocument"); + // Scalar interactive reference served at /docs. + expect(main.content).toContain('from "@scalar/nestjs-api-reference";'); + expect(main.content).toContain("apiReference("); + expect(main.content).toContain('app.use("/docs"'); + // DOCS_CORS_ORIGIN is a SEPARATE dev/docs allowance (never folded into the + // prod CORS_ORIGIN); prod CORS_ORIGIN handling stays intact. + expect(main.content).toContain('config.get("DOCS_CORS_ORIGIN")'); + expect(main.content).toContain('config.get("CORS_ORIGIN")'); + }); + + it("package.json — self-documenting deps (@nestjs/swagger + @scalar/nestjs-api-reference)", () => { + const files = emitScaffoldProject(ctxFor(richGraph().nodes)); + const pkg = fileByPath(files, "package.json"); + expect(pkg.content).toContain('"@nestjs/swagger"'); + expect(pkg.content).toContain('"@scalar/nestjs-api-reference"'); + }); + + it("env.validation.ts — Joi semasi DAIMA uretilir (DATABASE_URL required, fail-fast)", () => { + const files = emitScaffoldProject(ctxFor(richGraph().nodes)); + const v = fileByPath(files, "src/config/env.validation.ts"); + expect(v.content).toContain('import Joi from "joi";'); + expect(v.content).toContain("export const validationSchema = Joi.object({"); + // DATABASE_URL daima zorunlu (TypeORM forRootAsync bunu getOrThrow ile okur). + expect(v.content).toContain("DATABASE_URL: Joi.string().required(),"); + // DefaultValue olan public degisken default() alir (required ile celismez). + expect(v.content).toContain("PORT: Joi.number().default(3000),"); + // Secret + required (default'suz) -> required(). + expect(v.content).toContain("JWT_SECRET: Joi.string().required(),"); + // M1: DB havuz/timeout/retry knob'lari (default'lu opsiyonel sayilar). + expect(v.content).toContain("DB_POOL_MAX: Joi.number().default(10),"); + expect(v.content).toContain("DB_CONNECTION_TIMEOUT_MS: Joi.number().default(10000),"); + expect(v.content).toContain("DB_RETRY_ATTEMPTS: Joi.number().default(10),"); + expect(v.content).toContain("DB_RETRY_DELAY_MS: Joi.number().default(3000),"); + // L2: CORS_ORIGIN opsiyonel (bossa kapali), BODY_LIMIT default'lu. + expect(v.content).toContain('CORS_ORIGIN: Joi.string().allow("").optional(),'); + expect(v.content).toContain('BODY_LIMIT: Joi.string().default("1mb"),'); + }); + + it("tum icerikler tek satir sonu ile biter", () => { + const files = emitScaffoldProject(ctxFor(richGraph().nodes, richGraph().edges)); + for (const f of files) { + expect(f.content.endsWith("\n")).toBe(true); + expect(f.content.endsWith("\n\n")).toBe(false); + } + }); + + it("DETERMINISM: same graph twice -> byte-identical", () => { + const { nodes, edges } = richGraph(); + const a = emitScaffoldProject(ctxFor(nodes, edges)); + const b = emitScaffoldProject(ctxFor(nodes, edges)); + expect(a.map((f) => f.content)).toEqual(b.map((f) => f.content)); + }); + + it("snapshot — app.module.ts tam icerik (INCE; CoreModule + feature'lar)", () => { + const { nodes, edges } = richGraph(); + const app = fileByPath(emitScaffoldProject(ctxFor(nodes, edges)), "src/app.module.ts"); + expect(app.content).toMatchInlineSnapshot(` + "import { Module } from "@nestjs/common"; + import { ClockModule } from "./clock/clock.module"; + import { CoreModule } from "./core/core.module"; + import { HealthModule } from "./health/health.module"; + import { UsersModule } from "./users/users.module"; + + @Module({ + imports: [ + CoreModule, + ClockModule, + HealthModule, + UsersModule, + ], + }) + export class AppModule {} + " + `); + }); + + it("snapshot — core.module.ts tam icerik (TUM root altyapi + APP_FILTER + Pino)", () => { + const { nodes, edges } = richGraph(); + const core = fileByPath(emitScaffoldProject(ctxFor(nodes, edges)), "src/core/core.module.ts"); + expect(core.content).toMatchInlineSnapshot(` + "import { Module } from "@nestjs/common"; + import { ConfigModule, ConfigService } from "@nestjs/config"; + import { APP_FILTER } from "@nestjs/core"; + import { TypeOrmModule } from "@nestjs/typeorm"; + import { LoggerModule } from "nestjs-pino"; + import { SnakeNamingStrategy } from "typeorm-naming-strategies"; + import configuration from "../config/configuration"; + import { validationSchema } from "../config/env.validation"; + import { AllExceptionsFilter } from "../shared/filters/all-exceptions.filter"; + + /** + * Solarch-generated core infrastructure module. It gathers everything that is + * registered exactly ONCE across the application (Config/Logger/TypeORM and, per + * the graph, Cache/Queue/Schedule/Events) + the global exception filter. AppModule + * imports only this; root forRoot/register is never repeated anywhere else. + */ + @Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true, load: [configuration], validationSchema }), + LoggerModule.forRoot({ + pinoHttp: { + level: process.env.LOG_LEVEL ?? (process.env.NODE_ENV === "production" ? "info" : "debug"), + transport: + process.env.NODE_ENV === "production" + ? undefined + : { target: "pino-pretty", options: { singleLine: true } }, + }, + }), + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: "postgres" as const, + url: config.getOrThrow("DATABASE_URL"), + autoLoadEntities: true, + synchronize: false, + // Map PascalCase/camelCase entity members to snake_case DB columns + // (same strategy as data-source.ts, consistent with the migrations). + namingStrategy: new SnakeNamingStrategy(), + // Pool + timeout (passed to the pg driver via \`extra\`). + extra: { + max: config.get("DB_POOL_MAX") ?? 10, + connectionTimeoutMillis: config.get("DB_CONNECTION_TIMEOUT_MS") ?? 10000, + }, + // Bounded retry (NO infinite retry; it stops at boot sooner or later). + retryAttempts: config.get("DB_RETRY_ATTEMPTS") ?? 10, + retryDelay: config.get("DB_RETRY_DELAY_MS") ?? 3000, + }), + }), + ], + providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }], + }) + export class CoreModule {} + " + `); + }); + + /* ── Edge-case: bos graph / kayip env ──────────────────────────────── */ + it("EDGE-CASE: bos graph -> sabit iskelet, app.module ince, env varsayilan", () => { + const files = emitScaffoldProject(ctxFor([], [])); + // 15 dosya: temel iskelet (package/tsconfig x2/nest-cli/jest-e2e/.gitignore/ + // main/app.module/core.module/filter/env.validation/data-source/e2e/.env.example/ + // README). EnvVar node yok -> configuration.ts NONE; auth/roles stub NONE. + expect(files).toHaveLength(15); + + const app = fileByPath(files, "src/app.module.ts"); + // INCE app.module: yalniz CoreModule; root forRoot CoreModule'de. + expect(app.content).toContain(" CoreModule,"); + expect(app.content).not.toContain("TypeOrmModule.forRootAsync"); + expect(app.content).not.toContain("controllers:"); + expect(app.content).not.toContain("providers:"); + + const core = fileByPath(files, "src/core/core.module.ts"); + // EnvVar yok -> load: [configuration] NONE. + expect(core.content).toContain("ConfigModule.forRoot({ isGlobal: true, validationSchema })"); + expect(core.content).toContain("TypeOrmModule.forRootAsync({"); + // env.validation.ts daima var (DATABASE_URL zorunlu). + const v = fileByPath(files, "src/config/env.validation.ts"); + expect(v.content).toContain("DATABASE_URL: Joi.string().required(),"); + + const env = fileByPath(files, ".env.example"); + // Env node yok -> makul varsayilanlara duser. + expect(env.content).toContain("PORT=3000"); + expect(env.content).toContain("DATABASE_URL=postgres://user:password@localhost:5432/app"); + // Uretilen hicbir env satiri yok (env node'u yok), ama dosya gecerli. + expect(env.content).not.toContain(""); + }); + + it("EDGE-CASE: Module node'suz tek Controller bile kendi feature modulunu alir (sahipsiz kalmaz)", () => { + // Yalniz bir Controller (hic Module / CALLS edge yok) -> kendi feature'i + // ("ping") + sentezlenmis PingModule; app.module ham controller NOT, + // PingModule'u import eder -> DI tam. + const lone = node("Controller", { + ControllerName: "PingController", + Description: "ping", + BaseRoute: "ping", + Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: false }], + }); + const files = emitScaffoldProject(ctxFor([lone], [])); + const app = fileByPath(files, "src/app.module.ts"); + expect(app.content).toContain(" PingModule,"); + expect(app.content).toContain('import { PingModule } from "./ping/ping.module";'); + // Ham controller app.module'e GIRMEZ (feature modulune kapsullendi). + expect(app.content).not.toContain("controllers:"); + expect(app.content).not.toContain("PingController"); + }); +}); + +describe("fillDepsPackageJson (dogrulanmis in-app fill deps SUPERSET)", () => { + it("buildPackageJson'in TUM kosullu deps'lerini + test toolchain'ini icerir", () => { + const pkg = JSON.parse(fillDepsPackageJson()) as { + dependencies: Record; + devDependencies: Record; + }; + // Kosullu deps (cache/queue/http/schedule/event-emitter/redis) hepsi acik olmali: + // cache node_modules'un uretilen HER import'u cozmesi gerekir. + for (const dep of ["@nestjs/cache-manager", "cache-manager", "@keyv/redis", "@nestjs/bullmq", "bullmq", "@nestjs/axios", "axios", "@nestjs/schedule", "@nestjs/event-emitter"]) { + expect(pkg.dependencies[dep], `eksik dep: ${dep}`).toBeDefined(); + } + // Cekirdek runtime + tsc/jest (dogrulama bunlarsiz kosamaz). + expect(pkg.dependencies["typeorm"]).toBeDefined(); + expect(pkg.dependencies["@nestjs/typeorm"]).toBeDefined(); + expect(pkg.devDependencies["typescript"]).toBeDefined(); + expect(pkg.devDependencies["jest"]).toBeDefined(); + expect(pkg.devDependencies["ts-jest"]).toBeDefined(); + }); + + it("tsgo (native-preview) YALNIZ fill-deps cache'inde — kullanici projesine sizmaz", () => { + const fillDeps = JSON.parse(fillDepsPackageJson()) as { devDependencies: Record }; + // Cache: in-app SOLARCH_USE_TSGO=1 gecidi icin tsgo binary'sini bulur. + expect(fillDeps.devDependencies["@typescript/native-preview"]).toBeDefined(); + // Uretilen kullanici projesi (emitScaffoldProject package.json): pre-release arac GIRMEZ. + const userPkg = fileByPath(emitScaffoldProject(ctxFor(richGraph().nodes, richGraph().edges)), "package.json"); + expect(userPkg.content).not.toContain("native-preview"); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/scaffold.emitter.ts b/apps/server/src/codegen/emitters/nestjs/scaffold.emitter.ts new file mode 100644 index 0000000..a0cf2da --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/scaffold.emitter.ts @@ -0,0 +1,1319 @@ +import type { EmitterContext, GeneratedFile, ScaffoldEmitter } from "../../types"; +import { propsOf, type CodeNode } from "../../ir"; +import { importPathOf, pascalCase, relativeImportPath } from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers } from "../../surgical"; +import { isLoginEndpoint } from "./controller.emitter"; +import type { EnvironmentVariableNode } from "../../../nodes/schemas"; + +/** EnvironmentVariable, foundation IR'in PropsByKind tablosunda yer almaz; bu + * yuzden propsOf<"EnvironmentVariable"> yoktur. Sema tipiyle dogrudan daraltiriz + * (DB zaten Zod-dogrulanmis — yalniz tip daraltma, calisma zamani donusumu yok). */ +type EnvProps = EnvironmentVariableNode["properties"]; +const envPropsOf = (node: CodeNode): EnvProps => node.properties as EnvProps; + +/* ──────────────────────────────────────────────────────────────────────── + * scaffold.emitter.ts — GRAPH-FARKINDA proje-seviyesi iskelet (ScaffoldEmitter). + * + * Cekirdekteki sabit `scaffold.ts` (emitScaffold) bir PLACEHOLDER'dir. Bu emitter + * onun graph-farkinda, uretim-kalitesinde surumudur. Entegrasyon fazi montaj + * noktasinda (codegen.service) emitScaffoldProject cagirir (registry'de yer almaz — + * ScaffoldEmitter node'a bagli degildir). + * + * Sozlesme: + * - named export, default NONE: `export const emitScaffoldProject: ScaffoldEmitter`. + * - SAF fonksiyon: (ctx) -> GeneratedFile[]. I/O yok, throw yok. + * - Yollar her zaman filePathFor / relativeImportPath ile (hardcode'lar SABIT). + * - import'lar ImportCollector ile sirali (elle "import ..." YASAK). + * - DETERMINISTIC: tum koleksiyonlar isme gore sirali (graph.allOf zaten sirali), + * timestamp/random NONE, sabit version pin'leri. Ayni graph -> byte-identical. + * - Her icerik tek "\n" ile biter. surgicalMarkers countSurgicalMarkers ile. + * + * ARCHITECTURE (modern + optimize NestJS, Encore best-practice): + * package.json, tsconfig.json, tsconfig.build.json, nest-cli.json — sabit + * .gitignore, jest-e2e.json — sabit (H6) + * src/main.ts — NestFactory(bufferLogs) + Pino logger + ValidationPipe + * src/app.module.ts — INCE: yalniz CoreModule + CommonModule + feature module + * src/core/core.module.ts— TUM root forRoot/register (Config/TypeORM/Cache/Bull/ + * Schedule/EventEmitter/Pino) + APP_FILTER (H1/H2/H3) + * src/shared/filters/all-exceptions.filter.ts — global exception filter (H1) + * src/shared/guards/auth.guard.ts — gercek JWT guard (jsonwebtoken) + * src/shared/decorators/roles.decorator.ts — paylasimli stub decorator + * src/shared/decorators/current-user.decorator.ts — @CurrentUser + AuthUser/AuthResponse + * src/config/env.validation.ts — Joi fail-fast semasi + * src/config/configuration.ts — (EnvVar varsa) tipli config + * src/data-source.ts — TypeORM CLI DataSource (H5) + * .env.example (KOKTE) — EnvVar node'lari (H4) + * test/app.e2e-spec.ts — smoke e2e (H6) + * README.md — uretim + surgical notlari + * ──────────────────────────────────────────────────────────────────────── */ + +export const emitScaffoldProject: ScaffoldEmitter = (ctx: EmitterContext): GeneratedFile[] => { + const infra = scanInfraUsage(ctx); + + const files: GeneratedFile[] = [ + json("package.json", buildPackageJson(infra)), + json("tsconfig.json", TSCONFIG_JSON), + json("tsconfig.build.json", TSCONFIG_BUILD_JSON), + json("nest-cli.json", NEST_CLI_JSON), + json("jest-e2e.json", JEST_E2E_JSON), + file(".gitignore", GITIGNORE, "markdown"), + ts("src/main.ts", MAIN_TS), + ts("src/app.module.ts", buildAppModule(ctx)), + // core/core.module.ts — TUM root forRoot/register + APP_FILTER + Pino logger. + // AppModule artik yalniz bunu (+ CommonModule + feature'lar) import eder. + ts("src/core/core.module.ts", buildCoreModule(infra)), + // shared/filters/all-exceptions.filter.ts — global exception filter (tutarli zarf). + ts("src/shared/filters/all-exceptions.filter.ts", ALL_EXCEPTIONS_FILTER_TS), + // env.validation.ts — Joi semasi. DAIMA uretilir: en az DATABASE_URL zorunlu + + // EnvVar node'larindan turetilen kurallar. Gecersiz/eksik env BOOT'ta firlatir. + ts("src/config/env.validation.ts", buildEnvValidation(infra)), + // data-source.ts — TypeORM CLI DataSource (migration:run icin). DAIMA uretilir; + // migration yoksa bile derlenebilir (bos migrations dizini glob'u). + ts("src/data-source.ts", buildDataSource()), + // test/app.e2e-spec.ts — smoke e2e: AppModule boot + GET / 404 (Nest 404 zarfi). + ts("test/app.e2e-spec.ts", APP_E2E_SPEC_TS), + file(".env.example", buildEnvExample(ctx, infra), "env"), + file("README.md", README_MD, "markdown"), + ]; + + // ENV -> TIPLI CONFIG: graph'ta EnvironmentVariable node'lari VARSA src/config/ + // configuration.ts uretilir (ConfigModule.forRoot load: [configuration]). + if (infra.envNodes.length > 0) { + files.push(ts("src/config/configuration.ts", buildConfiguration(infra.envNodes))); + } + + // controller.emitter, RequiresAuth/RequiredRoles olan endpoint'ler icin + // `shared/guards/auth.guard` ve `shared/decorators/roles.decorator` import eder. + // O dosyalar baska HICBIR yerde uretilmez -> derleme TS2307 verir. Graph'ta en + // az bir kullanan endpoint VARSA stub'larini uret (yollar controller import'lariyla + // ayni: src/shared/...). Kullanilmiyorsa uretme. + const usage = scanAuthUsage(ctx); + if (usage.usesAuth) { + files.push(ts("src/shared/guards/auth.guard.ts", AUTH_GUARD_TS)); + // Auth capability primitive'leri — Login/Register fill'i bunlari KULLANIR + // (duz-metin sifre / sahte token yerine). AuthGuard ile JWT tek-kaynak. + files.push(ts("src/shared/auth/password.ts", PASSWORD_TS)); + files.push(ts("src/shared/auth/auth-token.ts", AUTH_TOKEN_TS)); + } + if (usage.usesRoles) { + files.push(ts("src/shared/decorators/roles.decorator.ts", ROLES_DECORATOR_TS)); + // RBAC WIRE (#39): @Roles metadata'sini OKUYAN gercek guard. roles.decorator + // tek basina olu olurdu; RolesGuard Reflector ile metadata'yi enforce eder. + files.push(ts("src/shared/guards/roles.guard.ts", ROLES_GUARD_TS)); + } + // current-user.decorator.ts — @CurrentUser param decorator + AuthUser/AuthResponse + // tipleri. RequiresAuth endpoint'leri (Finding #8) ve login endpoint'leri buradan + // import eder; baska yerde uretilmez -> emit etmezsek TS2307. + if (usage.usesCurrentUser) + files.push(ts("src/shared/decorators/current-user.decorator.ts", CURRENT_USER_DECORATOR_TS)); + + return files; +}; + +/* ── Mimari altyapi taramasi (root registration + dependency kararlari) ──── + * Graph'taki kind'lara gore hangi @nestjs altyapi modullerinin app root'a + * kaydedilecegini (artik CoreModule'de) ve package.json'a hangi deps'in + * eklenecegini belirler. Tek gecis, tek kaynak — core.module + package.json + * AYNI flag'leri okur. */ +interface InfraUsage { + /** @nestjs/cache-manager (Cache node varsa). */ + usesCache: boolean; + /** Cache.Engine === "Redis" olan en az bir Cache var mi? (Redis store dep). */ + usesRedisCache: boolean; + /** @nestjs/bullmq + BullModule.forRoot (MessageQueue veya queue-handler varsa). */ + usesQueue: boolean; + /** @nestjs/schedule + ScheduleModule.forRoot (Worker varsa). */ + usesSchedule: boolean; + /** @nestjs/event-emitter + EventEmitterModule.forRoot (event-tabanli handler varsa). */ + usesEventEmitter: boolean; + /** @nestjs/axios (ExternalService varsa). */ + usesHttp: boolean; + /** Auth kullaniliyor mu (RequiresAuth endpoint) — gercek AuthGuard jsonwebtoken + * ile JWT dogrular → jsonwebtoken + @types/jsonwebtoken dep'i kosullu eklenir. */ + usesAuth: boolean; + /** EnvironmentVariable node'lari (tipli config + .env.example). */ + envNodes: CodeNode[]; +} + +function scanInfraUsage(ctx: EmitterContext): InfraUsage { + const graph = ctx.graph; + const caches = graph.allOf("Cache"); + const externals = graph.allOf("ExternalService"); + const workers = graph.allOf("Worker"); + const queues = graph.allOf("MessageQueue"); + const handlers = graph.allOf("EventHandler"); + const envNodes = graph.allOf("EnvironmentVariable"); + + // Her EventHandler kuyruk-tabanli (SUBSCRIBES/QueueRef) mi, olay-tabanli + // (@OnEvent) mi? -> BullModule vs EventEmitterModule karari. + let usesEventEmitter = false; + let usesQueueHandler = false; + for (const h of handlers) { + if (handlerIsQueueBased(h, graph)) usesQueueHandler = true; + else usesEventEmitter = true; + } + + const usesRedisCache = caches.some( + (c) => (c.properties as Record).Engine === "Redis", + ); + const usesHttp = externals.length > 0; + + return { + usesCache: caches.length > 0, + usesRedisCache, + usesQueue: queues.length > 0 || usesQueueHandler, + usesSchedule: workers.length > 0, + usesEventEmitter, + usesHttp, + usesAuth: scanAuthUsage(ctx).usesAuth, + envNodes, + }; +} + +/** Bir EventHandler kuyruk-tabanli mi? (SUBSCRIBES edge'i veya QueueRef property'si + * ile bir MessageQueue'ya bagli.) event-handler.emitter ile ayni cozum. */ +function handlerIsQueueBased(handler: CodeNode, graph: EmitterContext["graph"]): boolean { + for (const e of graph.outEdges(handler.id, "SUBSCRIBES")) { + const tgt = graph.byId(e.targetNodeId); + if (tgt && tgt.kindOf() === "MessageQueue") return true; + } + const queueRef = (handler.properties as Record).QueueRef; + if (typeof queueRef === "string" && queueRef.length > 0) { + return graph.resolveRef("MessageQueue", queueRef) !== null; + } + return false; +} + +/** Graph'taki Controller endpoint'lerinde RequiresAuth / RequiredRoles / + * current-user.decorator kullanimi var mi? (controller.emitter ile AYNI kosullar.) + * + * usesCurrentUser: controller.emitter `shared/decorators/current-user.decorator` + * dosyasindan import uretiyor mu? Iki yol: + * - RequiresAuth bir endpoint -> @CurrentUser() user: AuthUser parametresi + * - ResponseDTORef WITHOUT login endpoint -> Promise donus + * Her iki durumda da o dosya BASKA hicbir yerde uretilmez -> TS2307. Bu yuzden + * bu kosullardan biri tutuyorsa current-user.decorator.ts emit edilmeli. */ +function scanAuthUsage( + ctx: EmitterContext, +): { usesAuth: boolean; usesRoles: boolean; usesCurrentUser: boolean } { + let usesAuth = false; + let usesRoles = false; + let usesCurrentUser = false; + for (const ctrl of ctx.graph.allOf("Controller")) { + for (const ep of propsOf<"Controller">(ctrl).Endpoints ?? []) { + if (ep.RequiresAuth) { + usesAuth = true; + usesCurrentUser = true; // @CurrentUser() user: AuthUser + } + if ((ep.RequiredRoles ?? []).length > 0) usesRoles = true; + // ResponseDTORef WITHOUT login endpoint -> Promise donus. + if (!ep.ResponseDTORef && isLoginEndpoint(ep)) usesCurrentUser = true; + } + } + return { usesAuth, usesRoles, usesCurrentUser }; +} + +/* ── src/app.module.ts (INCE — yalniz kompozisyon) ───────────────────────── + * H3: app.module artik root forRoot/register ICERMEZ. Yalniz: + * - CoreModule (tum root altyapi + APP_FILTER + Pino — TEK import) + * - CommonModule (varsa; feature-bagsiz altyapi) + * - Module'ler (slug'a sirali) + * Feature listesi slug'a gore sirali (determinizm). + * ──────────────────────────────────────────────────────────────────────── */ +function buildAppModule(ctx: EmitterContext): string { + const graph = ctx.graph; + const appModulePath = "src/app.module.ts"; + + const imports = new ImportCollector(); + imports.add("Module", "@nestjs/common"); + imports.add("CoreModule", importPathOf(relativeImportPath(appModulePath, "src/core/core.module.ts"))); + + const moduleClassNames: string[] = ["CoreModule"]; + + // Tum feature modullerini import et (slug'a sirali) -> imports[]. + for (const feature of graph.features()) { + const className = `${pascalCase(feature.slug)}Module`; + const modPath = `src/${feature.slug}/${feature.slug}.module.ts`; + imports.add(className, importPathOf(relativeImportPath(appModulePath, modPath))); + moduleClassNames.push(className); + } + // CommonModule (feature-bagsiz altyapi: kuyruk/handler/cache + paylasimli HTTP + // giris katmani) varsa onu da import et -> orphan provider KALMAZ. + if (graph.commonFeature()) { + imports.add("CommonModule", importPathOf(relativeImportPath(appModulePath, "src/common/common.module.ts"))); + moduleClassNames.push("CommonModule"); + } + + const moduleImportLines = moduleClassNames.map((c) => ` ${c},`); + + const lines = [ + imports.render(), + "", + "@Module({", + ` imports: [\n${moduleImportLines.join("\n")}\n ],`, + "})", + "export class AppModule {}", + ]; + return lines.join("\n"); +} + +/* ── src/core/core.module.ts (TUM ROOT ALTYAPI — H1/H2/H3) ──────────────── + * Uygulama genelinde TEK kez kaydedilen her sey burada toplanir: + * - ConfigModule.forRoot (isGlobal + Joi validationSchema, fail-fast) + * - LoggerModule.forRoot (nestjs-pino structured logging — H2) + * - TypeOrmModule.forRootAsync (ConfigService -> DATABASE_URL) + * - CacheModule.register (Cache varsa) + * - BullModule.forRoot (Queue varsa) + * - ScheduleModule.forRoot (Worker varsa) + * - EventEmitterModule.forRoot (event-tabanli handler varsa) + * - APP_FILTER -> AllExceptionsFilter (global exception filter — H1) + * @Global NOTDIR: AppModule'de tek import yeter (Nest root altyapi modulleri + * zaten kendi global token'larini —ConfigService/Logger/DataSource— yayar). + * ──────────────────────────────────────────────────────────────────────── */ +function buildCoreModule(infra: InfraUsage): string { + const coreModulePath = "src/core/core.module.ts"; + const imports = new ImportCollector(); + imports.add("Module", "@nestjs/common"); + imports.add("APP_FILTER", "@nestjs/core"); + imports.add( + "AllExceptionsFilter", + importPathOf(relativeImportPath(coreModulePath, "src/shared/filters/all-exceptions.filter.ts")), + ); + + const rootImportLines: string[] = []; + + // ── ConfigModule.forRoot — DAIMA + FAIL-FAST. ───────────────────────────── + imports.add("ConfigModule", "@nestjs/config"); + imports.add("validationSchema", importPathOf(relativeImportPath(coreModulePath, "src/config/env.validation.ts"))); + if (infra.envNodes.length > 0) { + imports.addDefault("configuration", importPathOf(relativeImportPath(coreModulePath, "src/config/configuration.ts"))); + rootImportLines.push(" ConfigModule.forRoot({ isGlobal: true, load: [configuration], validationSchema }),"); + } else { + rootImportLines.push(" ConfigModule.forRoot({ isGlobal: true, validationSchema }),"); + } + + // ── LoggerModule.forRoot (nestjs-pino) — yapilandirilmis JSON loglama (H2). ─ + // DI'lanabilir Logger (@nestjs/common veya PinoLogger) her serviste mevcut + // olur; console.log yerine bu kullanilir. Dev'de pino-pretty (NODE_ENV ile). + imports.add("LoggerModule", "nestjs-pino"); + rootImportLines.push( + " LoggerModule.forRoot({", + " pinoHttp: {", + ' level: process.env.LOG_LEVEL ?? (process.env.NODE_ENV === "production" ? "info" : "debug"),', + " transport:", + ' process.env.NODE_ENV === "production"', + " ? undefined", + ' : { target: "pino-pretty", options: { singleLine: true } },', + " },", + " }),", + ); + + // ── TypeOrmModule.forRootAsync(ConfigService) — daima (Postgres). ───────── + // M1: baglanti havuzu + timeout + sinirli retry. ConfigService-gated; ilgili + // env yoksa makul sabit default'lara duser (sonsuz retry'i ONLER). Havuz + // ayarlari `extra` ile pg surucusune gecer (max acik baglanti + baglanti + // kurma timeout'u). retryAttempts/retryDelay TypeOrmModule'un kendi + // yeniden-baglanma davranisini sinirlar. + imports.add("ConfigService", "@nestjs/config"); + imports.add("TypeOrmModule", "@nestjs/typeorm"); + imports.add("SnakeNamingStrategy", "typeorm-naming-strategies"); + rootImportLines.push( + " TypeOrmModule.forRootAsync({", + " inject: [ConfigService],", + " useFactory: (config: ConfigService) => ({", + ' type: "postgres" as const,', + ' url: config.getOrThrow("DATABASE_URL"),', + " autoLoadEntities: true,", + " synchronize: false,", + " // Map PascalCase/camelCase entity members to snake_case DB columns", + " // (same strategy as data-source.ts, consistent with the migrations).", + " namingStrategy: new SnakeNamingStrategy(),", + " // Pool + timeout (passed to the pg driver via `extra`).", + " extra: {", + ' max: config.get("DB_POOL_MAX") ?? 10,', + ' connectionTimeoutMillis: config.get("DB_CONNECTION_TIMEOUT_MS") ?? 10000,', + " },", + " // Bounded retry (NO infinite retry; it stops at boot sooner or later).", + ' retryAttempts: config.get("DB_RETRY_ATTEMPTS") ?? 10,', + ' retryDelay: config.get("DB_RETRY_DELAY_MS") ?? 3000,', + " }),", + " }),", + ); + + // CacheModule.register({ isGlobal: true }) — CACHE_MANAGER token uygulama geneli. + if (infra.usesCache) { + imports.add("CacheModule", "@nestjs/cache-manager"); + rootImportLines.push(" CacheModule.register({ isGlobal: true }),"); + } + + // BullModule.forRoot({ connection }) — Redis baglantisi (Queue varsa). + if (infra.usesQueue) { + imports.add("BullModule", "@nestjs/bullmq"); + rootImportLines.push( + " BullModule.forRoot({", + " connection: {", + ' host: process.env.REDIS_HOST ?? "localhost",', + " port: Number(process.env.REDIS_PORT ?? 6379),", + " },", + " }),", + ); + } + + // ScheduleModule.forRoot() — @Cron handler'lari (Worker varsa) ateslensin. + if (infra.usesSchedule) { + imports.add("ScheduleModule", "@nestjs/schedule"); + rootImportLines.push(" ScheduleModule.forRoot(),"); + } + + // EventEmitterModule.forRoot() — @OnEvent handler'lari (event-tabanli) calissin. + if (infra.usesEventEmitter) { + imports.add("EventEmitterModule", "@nestjs/event-emitter"); + rootImportLines.push(" EventEmitterModule.forRoot(),"); + } + + const lines = [ + imports.render(), + "", + "/**", + " * Solarch-generated core infrastructure module. It gathers everything that is", + " * registered exactly ONCE across the application (Config/Logger/TypeORM and, per", + " * the graph, Cache/Queue/Schedule/Events) + the global exception filter. AppModule", + " * imports only this; root forRoot/register is never repeated anywhere else.", + " */", + "@Module({", + ` imports: [\n${rootImportLines.join("\n")}\n ],`, + " providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }],", + "})", + "export class CoreModule {}", + ]; + return lines.join("\n"); +} + +/* ── .env.example (KOKTE — H4; graph-farkinda) ───────────────────────────── + * EnvironmentVariable node'larindan (isme gore sirali) anahtar=deger satirlari. + * GUVENLIK: IsSecret=true ise deger ASLA yazilmaz -> "" placeholder. + * Proje kokune yazilir (".env" geleneksel olarak kokten okunur); src/.env.example + * NOT. + * ──────────────────────────────────────────────────────────────────────── */ +function buildEnvExample(ctx: EmitterContext, infra: InfraUsage): string { + const envNodes = ctx.graph.allOf("EnvironmentVariable"); + + const lines: string[] = [ + "# Solarch-generated NestJS application — environment variables", + ]; + + if (envNodes.length === 0) { + lines.push("PORT=3000", "DATABASE_URL=postgres://user:password@localhost:5432/app"); + } else { + for (const node of envNodes) { + const p = envPropsOf(node); + const key = node.name; + if (key.length === 0) continue; + + const envs = (p.Environment ?? []).join("/"); + const required = p.IsRequired === false ? "optional" : "required"; + const meta = envs.length > 0 ? `${p.Description} (${envs}; ${required})` : `${p.Description} (${required})`; + lines.push("", `# ${meta}`); + + const value = envValueFor(p); + lines.push(`${key}=${value}`); + } + } + + // ── Cekirdek calisma-zamani ayarlari (graph'tan bagimsiz; daima). ───────── + // M1: TypeORM havuz/timeout/retry. L2: CORS + govde siniri. Hepsi opsiyonel + // (Joi default'lari var); degerler yorum satirinda varsayilani gosterir. + lines.push("", "# TypeORM connection pool + timeout + bounded retry (M1)"); + lines.push("DB_POOL_MAX=10"); + lines.push("DB_CONNECTION_TIMEOUT_MS=10000"); + lines.push("DB_RETRY_ATTEMPTS=10"); + lines.push("DB_RETRY_DELAY_MS=3000"); + lines.push("", "# HTTP security (L2). If CORS_ORIGIN is empty, CORS is OFF (prod-safe)."); + lines.push("# Comma-separated list of origins, e.g.: https://app.example.com,https://admin.example.com"); + lines.push("CORS_ORIGIN="); + lines.push("BODY_LIMIT=1mb"); + lines.push("# Separate dev/docs CORS allowance for the Scalar /docs \"try it\" origin."); + lines.push("# Additive to CORS_ORIGIN; leave empty in prod (does NOT loosen CORS_ORIGIN)."); + lines.push("DOCS_CORS_ORIGIN="); + + // ── Mimari altyapi env anahtarlari (graph kind'larina gore, deterministik) ── + appendInfraEnvKeys(lines, ctx, infra); + + return lines.join("\n"); +} + +/** Queue (Redis) + ExternalService env anahtarlarini .env.example'a ekler. + * external-service.emitter ile ayni PREFIX (snakeCase(name).toUpperCase()) ve + * anahtar adlari (_BASE_URL/_TIMEOUT_SECONDS/_AUTH_TOKEN|_API_KEY). */ +function appendInfraEnvKeys(lines: string[], ctx: EmitterContext, infra: InfraUsage): void { + if (infra.usesQueue) { + lines.push("", "# BullMQ Redis connection (queues)"); + lines.push("REDIS_HOST=localhost", "REDIS_PORT=6379"); + } + + for (const ext of ctx.graph.allOf("ExternalService")) { + const p = ext.properties as Record; + const prefix = snakeUpper(ext.name); + if (prefix.length === 0) continue; + const desc = typeof p.Description === "string" ? p.Description : ext.name; + lines.push("", `# ${desc} (external service ${ext.name})`); + lines.push(`${prefix}_BASE_URL=`); + lines.push(`${prefix}_TIMEOUT_SECONDS=`); + const authType = p.AuthType; + if (authType === "API_Key") { + lines.push(`${prefix}_API_KEY=`); + } else if (authType === "Bearer" || authType === "Basic") { + lines.push(`${prefix}_AUTH_TOKEN=`); + } + } +} + +/** snakeCase(name).toUpperCase() — external-service.emitter envPrefix ile birebir. */ +function snakeUpper(input: string): string { + return input + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .split(/[\s\-_./]+/) + .filter((w) => w.length > 0) + .map((w) => w.toUpperCase()) + .join("_"); +} + +/** Bir env degiskeninin .env.example degeri. Secret ASLA gercek deger almaz. */ +function envValueFor(p: EnvProps): string { + if (p.IsSecret) { + return ""; + } + if (p.DefaultValue !== undefined && p.DefaultValue !== "") { + return p.DefaultValue; + } + switch (p.DataType) { + case "Number": + return "0"; + case "Boolean": + return "false"; + default: + return ""; + } +} + +/* ── GeneratedFile yardimcilari (scaffold.ts ile ayni desen) ──────────────── */ +function file(path: string, content: string, language: GeneratedFile["language"]): GeneratedFile { + const normalized = content.endsWith("\n") ? content : `${content}\n`; + return { path, content: normalized, language, surgicalMarkers: countSurgicalMarkers(normalized) }; +} +const ts = (p: string, c: string) => file(p, c, "typescript"); +const json = (p: string, c: string) => file(p, c, "json"); + +/* ── package.json (GRAPH-FARKINDA dependency secimi) ─────────────────────── + * Cekirdek deps daima; mimari altyapi deps KULLANILAN kind'lara gore eklenir. + * Surum pin'leri SABIT (determinizm); deps anahtarlari sirali. */ +function buildPackageJson(infra: InfraUsage): string { + const deps: Record = { + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + // @nestjs/swagger: builds the OpenAPI document from the @ApiTags/@ApiOperation/ + // @ApiProperty decorators (main.ts SwaggerModule.createDocument). The generated + // app self-documents. + "@nestjs/swagger": "^11.4.4", + "@nestjs/typeorm": "^11.0.0", + // @scalar/nestjs-api-reference: serves an interactive Scalar API reference at + // /docs from the in-memory OpenAPI document (main.ts apiReference middleware). + "@scalar/nestjs-api-reference": "^1.1.17", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + // dotenv: data-source.ts (TypeORM CLI) loads .env manually outside NestFactory + // (H5). It is imported directly there, so it must be an explicit dependency. + dotenv: "^17.0.0", + // express: main.ts json/urlencoded body-limit + CORS icin (L2). Platform + // altinda zaten var; dogrudan import edildigi icin acik dep yapilir. + express: "^5.0.0", + // helmet: guvenlik HTTP basliklari (main.ts, L2). + helmet: "^8.0.0", + joi: "^17.13.0", + // nestjs-pino + pino-http: yapilandirilmis JSON loglama (CoreModule, H2). + "nestjs-pino": "^4.1.0", + pg: "^8.13.1", + "pino-http": "^10.0.0", + "reflect-metadata": "^0.2.2", + rxjs: "^7.8.1", + typeorm: "^0.3.20", + // typeorm-naming-strategies: SnakeNamingStrategy maps PascalCase/camelCase + // entity members to snake_case DB columns (#10). Used by BOTH the runtime + // (CoreModule forRootAsync) and the migration CLI (data-source.ts), so entity + // property names line up with the snake_case columns the migrations create. + "typeorm-naming-strategies": "^4.1.0", + }; + const devDeps: Record = { + // Test/CI iskeleti (H6): jest + ts-jest + @nestjs/testing + supertest. + "@nestjs/cli": "^11.0.0", + "@nestjs/testing": "^11.0.0", + // @types/express: main.ts daima express json/urlencoded import eder (L2); + // Middleware emitter'i da Request/Response tiplerini kullanir. + "@types/express": "^5.0.0", + "@types/jest": "^29.5.0", + "@types/node": "^22.10.0", + "@types/supertest": "^6.0.0", + jest: "^29.7.0", + // pino-pretty: dev'de okunakli log (production'da devre disi). + "pino-pretty": "^11.0.0", + supertest: "^7.0.0", + "ts-jest": "^29.2.0", + "ts-node": "^10.9.0", + typescript: "^5.7.0", + }; + + if (infra.usesCache) { + deps["@nestjs/cache-manager"] = "^3.0.0"; + deps["cache-manager"] = "^6.0.0"; + if (infra.usesRedisCache) deps["@keyv/redis"] = "^4.0.0"; + } + if (infra.usesQueue) { + deps["@nestjs/bullmq"] = "^11.0.0"; + deps["bullmq"] = "^5.0.0"; + } + if (infra.usesHttp) { + deps["@nestjs/axios"] = "^4.0.0"; + deps["axios"] = "^1.7.0"; + } + if (infra.usesSchedule) deps["@nestjs/schedule"] = "^5.0.0"; + if (infra.usesEventEmitter) deps["@nestjs/event-emitter"] = "^3.0.0"; + // Auth: gercek AuthGuard Bearer JWT'yi dogrular (jsonwebtoken) + login servisi + // sifreyi bcrypt ile hash'ler/karsilastirir (bcryptjs — saf JS, native derleme yok). + if (infra.usesAuth) { + deps["jsonwebtoken"] = "^9.0.0"; + deps["bcryptjs"] = "^2.4.3"; + devDeps["@types/jsonwebtoken"] = "^9.0.0"; + devDeps["@types/bcryptjs"] = "^2.4.6"; + } + + return jsonStringify({ + name: "solarch-generated", + version: "0.1.0", + private: true, + // L4: paket-yoneticisi pin. README pnpm komutlari kullanir; Corepack bu alani + // okuyarak dogru pnpm surumunu etkinlestirir (tutarli kurulum). SABIT surum + // (determinizm); deterministik uretimde lockfile yazilmaz, pin yeterlidir. + packageManager: "pnpm@10.0.0", + scripts: { + build: "nest build", + start: "node dist/main.js", + "start:dev": "nest start --watch", + // H5: TypeORM CLI migration:run, data-source.ts uzerinden. + "db:migrate": "typeorm migration:run -d dist/data-source.js", + "db:migrate:revert": "typeorm migration:revert -d dist/data-source.js", + // H6: jest unit + e2e. + test: "jest", + "test:e2e": "jest --config jest-e2e.json", + }, + // H6: jest unit konfigurasyonu (ts-jest, src/ koku, *.spec.ts). + jest: { + moduleFileExtensions: ["js", "json", "ts"], + rootDir: "src", + testRegex: ".*\\.spec\\.ts$", + transform: { "^.+\\.(t|j)s$": "ts-jest" }, + collectCoverageFrom: ["**/*.(t|j)s"], + coverageDirectory: "../coverage", + testEnvironment: "node", + }, + dependencies: sortObject(deps), + devDependencies: sortObject(devDeps), + }); +} + +/** Sunucu-tarafi DOGRULANMIS fill icin node_modules cache'inin kurulacagi KANONIK + * SUPERSET package.json — buildPackageJson'in TUM kosullu deps'leri (cache/queue/ + * http/schedule/event-emitter) acik. Cache bundan kurulur → codegen'in emit + * edebilecegi HER import tsc tarafindan cozulur. Tek kaynak buildPackageJson → + * yeni bir dep eklenince cache de otomatik kapsar (drift yok). */ +export function fillDepsPackageJson(): string { + const pkg = JSON.parse( + buildPackageJson({ + usesCache: true, + usesRedisCache: true, + usesQueue: true, + usesSchedule: true, + usesEventEmitter: true, + usesHttp: true, + usesAuth: true, + envNodes: [], + }), + ) as { devDependencies?: Record }; + // tsgo (TypeScript 7.0 native): YALNIZ fill-deps cache'ine ekle → in-app DOGRULANMIS fill, + // SOLARCH_USE_TSGO=1 iken ~9x hizli tsc gecidi icin binary'yi bulur. Uretilen KULLANICI + // projelerine GIRMEZ (buildPackageJson'a dokunulmadi) — pre-release arac shipped output'a + // sizmasin. Flag yokken cache'te durur ama kullanilmaz. devDeps sirali tutulur (determinizm). + pkg.devDependencies = sortObject({ + ...(pkg.devDependencies ?? {}), + "@typescript/native-preview": "latest", + }); + return jsonStringify(pkg); +} + +/** Deterministik 2-bosluk JSON (anahtar sirasi verildigi gibi korunur). */ +function jsonStringify(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +/** Bir Record'un anahtarlarini alfabetik siralayip yeniden kurar (deterministik). */ +function sortObject(rec: Record): Record { + const out: Record = {}; + for (const k of Object.keys(rec).sort()) out[k] = rec[k]; + return out; +} + +/* ── src/config/env.validation.ts (Joi -> FAIL-FAST) ────────────────────── + * ConfigModule.forRoot({ validationSchema }) ile kullanilan Joi object semasi. + * Gecersiz/eksik bir env BOOT'ta firlatir. + * + * Kurallar: + * - DATABASE_URL: DAIMA Joi.string().required() (TypeORM forRootAsync bunu okur). + * - PORT: Joi.number().default(3000) (main.ts kullanir). + * - EnvironmentVariable node'lari: DataType -> Joi tipi; IsRequired -> required(); + * (secret WITHOUT) DefaultValue -> default(...). Isme gore sirali (determinizm). + * - usesQueue ise REDIS_HOST/REDIS_PORT (BullMQ baglantisi) eklenir. + * Hicbir gercek secret DEGERI gomulmez (yalniz tip/zorunluluk kurallari). ──── */ +function buildEnvValidation(infra: InfraUsage): string { + const imports = new ImportCollector(); + imports.addDefault("Joi", "joi"); + + const entries = new Map(); + entries.set("DATABASE_URL", "Joi.string().required()"); + entries.set("PORT", "Joi.number().default(3000)"); + // M1: TypeORM havuz/timeout/retry ayarlari (CoreModule forRootAsync okur). + entries.set("DB_POOL_MAX", "Joi.number().default(10)"); + entries.set("DB_CONNECTION_TIMEOUT_MS", "Joi.number().default(10000)"); + entries.set("DB_RETRY_ATTEMPTS", "Joi.number().default(10)"); + entries.set("DB_RETRY_DELAY_MS", "Joi.number().default(3000)"); + // L2: HTTP guvenligi. CORS_ORIGIN opsiyonel (bossa CORS kapali); BODY_LIMIT + // express body-parser limit dizesi (or. "1mb"). + entries.set("CORS_ORIGIN", "Joi.string().allow(\"\").optional()"); + entries.set("BODY_LIMIT", "Joi.string().default(\"1mb\")"); + // Self-documenting app: separate dev/docs CORS allowance for the Scalar /docs + // origin (additive to CORS_ORIGIN; never loosens it). + entries.set("DOCS_CORS_ORIGIN", "Joi.string().allow(\"\").optional()"); + if (infra.usesQueue) { + entries.set("REDIS_HOST", 'Joi.string().default("localhost")'); + entries.set("REDIS_PORT", "Joi.number().default(6379)"); + } + + for (const node of [...infra.envNodes].sort(byName)) { + const p = envPropsOf(node); + const key = node.name; + if (key.length === 0 || entries.has(key)) continue; + entries.set(key, joiRuleFor(p)); + } + + const lines: string[] = []; + lines.push("/**"); + lines.push(" * Solarch-generated environment-variable validation schema (Joi)."); + lines.push(" * Used with ConfigModule.forRoot({ validationSchema }); an invalid or"); + lines.push(" * missing env throws at BOOT (fail-fast). No real secret value is embedded."); + lines.push(" */"); + lines.push(`${imports.render()}`); + lines.push(""); + lines.push("export const validationSchema = Joi.object({"); + for (const [key, rule] of entries) { + lines.push(` ${key}: ${rule},`); + } + lines.push("});"); + return lines.join("\n"); +} + +/** Bir EnvVar node'unun Joi kurali (DataType + IsRequired + secret-olmayan + * DefaultValue). Secret deger ASLA gomulmez (yalniz tip/zorunluluk). */ +function joiRuleFor(p: EnvProps): string { + let base: string; + switch (p.DataType) { + case "Number": + base = "Joi.number()"; + break; + case "Boolean": + base = "Joi.boolean()"; + break; + default: + base = "Joi.string()"; + } + const hasDefault = !p.IsSecret && p.DefaultValue !== undefined && p.DefaultValue !== ""; + if (hasDefault) { + if (p.DataType === "Number") base += `.default(${Number(p.DefaultValue)})`; + else if (p.DataType === "Boolean") base += `.default(${p.DefaultValue === "true"})`; + else base += `.default(${JSON.stringify(p.DefaultValue)})`; + } else { + base += p.IsRequired === false ? ".optional()" : ".required()"; + } + return base; +} + +/* ── src/config/configuration.ts (ENV -> TIPLI CONFIG) ───────────────────── + * EnvironmentVariable node'larindan tipli bir config nesnesi uretir. */ +function buildConfiguration(envNodes: CodeNode[]): string { + const lines: string[] = []; + lines.push("/**"); + lines.push(" * Solarch-generated typed environment-variable configuration."); + lines.push(" * Loaded via ConfigModule.forRoot({ load: [configuration] }) and accessed"); + lines.push(" * in a typed way through ConfigService. Secret values are not"); + lines.push(" * embedded in code — they are only read from process.env."); + lines.push(" */"); + lines.push("export default () => ({"); + for (const node of [...envNodes].sort(byName)) { + const p = envPropsOf(node); + const key = node.name; + if (key.length === 0) continue; + const field = camelCaseKey(key); + lines.push(` ${field}: ${envReadExpr(p, key)},`); + } + lines.push("});"); + return lines.join("\n"); +} + +/** Bir env degiskeninin process.env okuma ifadesi (DataType'a gore donusumlu). */ +function envReadExpr(p: EnvProps, key: string): string { + const raw = `process.env.${key}`; + switch (p.DataType) { + case "Number": + return `${raw} === undefined ? undefined : Number(${raw})`; + case "Boolean": + return `${raw} === "true"`; + default: + return raw; + } +} + +/** Bir ENV anahtarini ("DATABASE_URL") camelCase alan adina ("databaseUrl"). */ +function camelCaseKey(key: string): string { + const words = key + .split(/[\s\-_./]+/) + .filter((w) => w.length > 0) + .map((w) => w.toLowerCase()); + if (words.length === 0) return "value"; + return words[0] + words.slice(1).map((w) => w[0].toUpperCase() + w.slice(1)).join(""); +} + +function byName(a: CodeNode, b: CodeNode): number { + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; +} + +/* ── src/data-source.ts (TypeORM CLI DataSource — H5) ────────────────────── + * `typeorm migration:run -d dist/data-source.js` bunu yukler. Calisma zamani + * uygulamasi (TypeOrmModule.forRootAsync) ile AYNI baglanti bilgisini paylasir + * (DATABASE_URL); entity'ler glob ile otomatik bulunur. migrations glob'u + * src/migrations/*.ts TS migration siniflarina bakar (orchestrator uretir). + * synchronize:false KORUNUR — sema yalniz migration ile degisir. ──────────── */ +function buildDataSource(): string { + return `import "reflect-metadata"; +import { config as loadEnv } from "dotenv"; +import { DataSource } from "typeorm"; +import { SnakeNamingStrategy } from "typeorm-naming-strategies"; + +// CLI context: load .env manually (NestFactory does not run here). The runtime +// application connection is provided by TypeOrmModule.forRootAsync(ConfigService); +// this DataSource is only for the migration CLI and reads the SAME DATABASE_URL. +loadEnv(); + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) { + throw new Error("DATABASE_URL is not defined; the migration CLI cannot run."); +} + +/** + * Solarch-generated TypeORM CLI DataSource. \`npm run db:migrate\` + * (typeorm migration:run -d dist/data-source.js) uses it. The entity and + * migration globs look at the compiled dist output; synchronize:false. + */ +export default new DataSource({ + type: "postgres", + url: databaseUrl, + entities: ["dist/**/*.entity.js"], + migrations: ["dist/migrations/*.js"], + // Same naming strategy as the runtime (CoreModule). Keeps entity members mapped + // to snake_case columns so the CLI sees the SAME schema the migrations create. + namingStrategy: new SnakeNamingStrategy(), + synchronize: false, +});`; +} + +// NOT: baseUrl NONE — uretilen import'lar tamamen relative (oluydu) + TS 7.0 (tsgo) baseUrl'i +// kaldirdi (TS5102). "types" ACIK: baseUrl gidince @types global cozumu explicit olmali +// (node = process/Buffer; jest = .spec.ts global'leri). Diger @types (express/supertest/jwt) +// import edilir → module-scoped, listelenmez. "lib":["ES2022"] DOM-collision fix'i (korunur). +const TSCONFIG_JSON = `{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022"], + "types": ["node", "jest"], + "outDir": "./dist", + "declaration": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}`; + +/* tsconfig.build.json (H6): nest build bunu kullanir — tsconfig'i extend eder, + * test/spec dosyalarini derlemeden haric tutar (dist temiz kalir). */ +const TSCONFIG_BUILD_JSON = `{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.e2e-spec.ts"] +}`; + +const NEST_CLI_JSON = `{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +}`; + +/* jest-e2e.json (H6): e2e testleri test/ kokunden, *.e2e-spec.ts ile calistirir. */ +const JEST_E2E_JSON = `{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\\\.(t|j)s$": "ts-jest" + } +}`; + +/* .gitignore (H6): node_modules / dist / .env (secret sizintisi onlenir). */ +const GITIGNORE = `# Dependencies +node_modules + +# Build output +dist + +# Environment variables (secrets) — .env.example is committed, .env NEVER +.env +.env.* +!.env.example + +# Test coverage + logs +coverage +*.log +`; + +/* ── shared/filters/all-exceptions.filter.ts (GLOBAL EXCEPTION FILTER — H1) ── + * @Catch() ile TUM hatalari yakalar; tutarli JSON zarfi doner: + * { statusCode, error, message, requestId, timestamp } + * HttpException -> kendi status'u + mesaji korunur; generic hata -> 500 + + * jenerik mesaj (ic hata DETAYI sizdirilmaz, yalniz sunucu tarafinda loglanir). */ +const ALL_EXCEPTIONS_FILTER_TS = `import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from "@nestjs/common"; + +/** Minimum HTTP surface the filter reads — INDEPENDENT of express/fastify (requires + * no type package). Carries the x-request-id request header / correlation id. */ +interface HttpRequestLike { + method: string; + url: string; + id?: string; + headers: Record; +} +interface HttpResponseLike { + status(code: number): { json(body: unknown): unknown }; +} + +/** + * Solarch-generated global exception filter. Every error is returned in a + * consistent JSON envelope; the HttpException status/message is preserved, and on + * unexpected 500s the internal error detail is NEVER LEAKED to the client (logged on the server only). + */ +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + private readonly logger = new Logger(AllExceptionsFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const isHttp = exception instanceof HttpException; + const statusCode = isHttp ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; + + // HttpException -> message/error name is preserved; generic -> generic 500 (no leak). + let error = "Internal Server Error"; + let message: string | string[] = "An unexpected error occurred."; + if (isHttp) { + const body = exception.getResponse(); + if (typeof body === "string") { + message = body; + error = exception.name; + } else if (body && typeof body === "object") { + const obj = body as { error?: string; message?: string | string[] }; + error = obj.error ?? exception.name; + message = obj.message ?? exception.message; + } + } + + const requestId = + (request.headers["x-request-id"] as string | undefined) ?? request.id ?? undefined; + + if (!isHttp || statusCode >= HttpStatus.INTERNAL_SERVER_ERROR) { + // Unexpected/5xx error: log the full detail ON THE SERVER (not to the client). + this.logger.error( + \`\${request.method} \${request.url} -> \${statusCode}\`, + exception instanceof Error ? exception.stack : String(exception), + ); + } + + response.status(statusCode).json({ + statusCode, + error, + message, + requestId, + timestamp: new Date().toISOString(), + }); + } +}`; + +/* ── shared/guards/auth.guard.ts (GERCEK, capability-layer) ───────────────── + * controller.emitter @UseGuards(AuthGuard) urettiginde import edilen dosya. Artik + * PLACEHOLDER/surgical NOT: deterministik GERCEK guard — Bearer JWT'yi JWT_SECRET + * ile dogrular, cozulen claim'leri request.user'a koyar (@CurrentUser + RolesGuard + * okur), her basarisizlikta 401 atar. Auth strateji = JWT; token'i login servisi + * govdesi ayni JWT_SECRET ile imzalar (sub=user id, role=rol). 'as any' KULLANMAZ. */ +const AUTH_GUARD_TS = `import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"; +import type { Request } from "express"; +import { verifyAccessToken } from "../auth/auth-token"; + +/** + * Solarch-generated AuthGuard — verifies the Bearer JWT and populates request.user. + * Deterministic (NOT a surgical area): it extracts "Authorization: Bearer ", + * verifies it via verifyAccessToken (single source with the login service's + * signAccessToken — same JWT_SECRET), and assigns the decoded claims to request.user + * so @CurrentUser() and RolesGuard can read them. Throws 401 on any failure. + */ +@Injectable() +export class AuthGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const [scheme, token] = (request.headers.authorization ?? "").split(" "); + if (scheme !== "Bearer" || !token) { + throw new UnauthorizedException("Missing or malformed Authorization header"); + } + let claims; + try { + claims = verifyAccessToken(token); + } catch { + throw new UnauthorizedException("Invalid or expired token"); + } + request.user = { ...claims, id: String(claims.sub ?? claims.id ?? "") }; + return true; + } +}`; + +/* ── shared/auth/auth-token.ts (JWT sign/verify — TEK SOURCE) ──────────────── + * AuthGuard verifyAccessToken ile DOGRULAR; login servisi signAccessToken ile + * IMZALAR — ayni JWT_SECRET + algoritma. Login fill'i sahte 'token' yerine bunu + * cagirir (apiSurface'te gorunur; service.emitter auth servisine import eder). */ +const AUTH_TOKEN_TS = `import { sign, verify, type JwtPayload } from "jsonwebtoken"; + +/** The JWT signing/verification secret (fail fast if not configured). */ +function secret(): string { + const value = process.env.JWT_SECRET; + if (!value) { + throw new Error("JWT_SECRET is not configured"); + } + return value; +} + +/** + * Sign an access token for an authenticated user. Put the user id in \`sub\` and the + * role in \`role\` so AuthGuard / RolesGuard can read them. Default expiry: 1 hour. + */ +export function signAccessToken( + claims: { sub: string; role?: string } & Record, + expiresInSeconds = 3600, +): string { + return sign(claims, secret(), { expiresIn: expiresInSeconds }); +} + +/** Verify an access token and return its claims; throws if invalid or expired. */ +export function verifyAccessToken(token: string): JwtPayload { + const decoded = verify(token, secret()); + if (typeof decoded === "string") { + throw new Error("Invalid token payload"); + } + return decoded; +}`; + +/* ── shared/auth/password.ts (bcrypt hash/compare) ────────────────────────── + * Login/Register fill'i icin: hashPassword (kullanici olustururken passwordHash'e + * yaz) + comparePassword (kimlik dogrularken). Duz-metin karsilastirma yerine. */ +const PASSWORD_TS = `import { hash, compare } from "bcryptjs"; + +/** Cost factor for bcrypt hashing. */ +const ROUNDS = 10; + +/** Hash a plaintext password — store the result in the user's passwordHash column. */ +export function hashPassword(plain: string): Promise { + return hash(plain, ROUNDS); +} + +/** Compare a plaintext password against a stored hash. Returns true when they match. */ +export function comparePassword(plain: string, passwordHash: string): Promise { + return compare(plain, passwordHash); +}`; + +/* ── shared/decorators/roles.decorator.ts (stub) ─────────────────────────── + * controller.emitter @Roles("admin", ...) urettiginde import edilen dosya. + * Metadata yazan deterministik bir decorator (RolesGuard ile eslesmek uzere). */ +const ROLES_DECORATOR_TS = `import { SetMetadata } from "@nestjs/common"; + +/** Metadata key that marks the roles required for a handler. */ +export const ROLES_KEY = "roles"; + +/** + * Solarch-generated Roles decorator. Used like @Roles("admin", "owner"); it writes + * the roles into the route metadata. RolesGuard (shared/guards/roles.guard.ts) reads + * this metadata via Reflector and enforces it. + */ +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);`; + +/* ── shared/guards/roles.guard.ts (GERCEK guard, stub NOT) ──────────────── + * RBAC WIRE (#39): @Roles(...) ile yazilan ROLES_KEY metadata'sini Reflector ile + * OKUR ve enforce eder. Rol gerekmeyen route gecer; gerektiren route'ta + * request.user.role gerekli rollerden biri olmali. request.user'i AuthGuard + * (authentication) yerlestirir; RolesGuard yalniz authorization yapar. Reflector + * NestJS cekirdeginden otomatik enjekte edilir (provider kaydi gerekmez). */ +const ROLES_GUARD_TS = `import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { ROLES_KEY } from "../decorators/roles.decorator"; + +/** + * Solarch-generated RolesGuard. Reads the roles written by @Roles(...) and enforces + * them: a route with no required roles passes; otherwise request.user.role must be one + * of the required roles. request.user is populated by AuthGuard (authentication); this + * guard only does authorization. + */ +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const required = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!required || required.length === 0) return true; + const request = context.switchToHttp().getRequest<{ user?: { role?: string } }>(); + const role = request.user?.role; + if (role === undefined) return false; + // CASE-INSENSITIVE rol eslesmesi: graf'taki @Roles("ADMIN") ile enum/token'daki + // "admin" casing'i uyusmasa da RBAC calisir (sessiz kirilma yok). + const normalized = role.toLowerCase(); + return required.some((r) => r.toLowerCase() === normalized); + } +}`; + +/* ── shared/decorators/current-user.decorator.ts (stub) ──────────────────── + * controller.emitter @CurrentUser() user: AuthUser urettiginde (RequiresAuth) + * VE login endpoint Promise dondugunde import edilen dosya. + * Tek dosyada uc export: AuthUser (request.user sekli), AuthResponse (login + * token zarfi), CurrentUser (request.user'i cikaran param decorator). */ +const CURRENT_USER_DECORATOR_TS = `import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import type { Request } from "express"; + +/** + * Shape of the authenticated user placed on the request by AuthGuard. + * Extend this with your own claims (e.g. roles, email) as needed. + */ +export interface AuthUser { + id: string; +} + +/** + * Consistent envelope returned by login/authenticate endpoints. + * Fill in the real token issuing in the surgical service body. + */ +export interface AuthResponse { + accessToken: string; +} + +/** + * Solarch-generated @CurrentUser() param decorator. Reads the authenticated + * user that AuthGuard placed on request.user. Use it as: + * handler(@CurrentUser() user: AuthUser) { ... user.id ... } + * If no user is present (guard not yet wired), this returns undefined — wire + * the real guard so the user is always available on protected routes. + */ +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): AuthUser | undefined => { + const request = ctx.switchToHttp().getRequest(); + return request.user; + }, +);`; + +const MAIN_TS = `import { NestFactory } from "@nestjs/core"; +import { ValidationPipe } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import { apiReference } from "@scalar/nestjs-api-reference"; +import helmet from "helmet"; +import { json, urlencoded } from "express"; +import { Logger } from "nestjs-pino"; +import { AppModule } from "./app.module"; + +async function bootstrap() { + // bufferLogs: true -> early logs are buffered until Pino is ready; then + // app.useLogger(...) takes over the configured logger. abortOnError + // (the default) throws WITHOUT EVER STARTING the app if ConfigModule's Joi + // validation fails (e.g. DATABASE_URL missing) — fail-fast. + const app = await NestFactory.create(AppModule, { bufferLogs: true }); + app.useLogger(app.get(Logger)); + const config = app.get(ConfigService); + + // L2: security headers (helmet) — prod-safe defaults. + app.use(helmet()); + + // L2: request body size limit (narrows the DoS surface). ConfigService-gated. + const bodyLimit = config.get("BODY_LIMIT") ?? "1mb"; + app.use(json({ limit: bodyLimit })); + app.use(urlencoded({ extended: true, limit: bodyLimit })); + + // L2: CORS — ConfigService-gated. CORS_ORIGIN is the prod allowlist (comma- + // separated, exact match). DOCS_CORS_ORIGIN is a SEPARATE dev/docs allowance + // (e.g. the origin that renders the Scalar reference and issues "try it" + // requests against this server) — it is purely additive and NEVER loosens the + // prod CORS_ORIGIN. If neither is set, CORS stays OFF (prod-safe default). + const splitOrigins = (raw: string | undefined): string[] => + (raw ?? "").split(",").map((o) => o.trim()).filter((o) => o.length > 0); + const corsOrigins = [ + ...splitOrigins(config.get("CORS_ORIGIN")), + ...splitOrigins(config.get("DOCS_CORS_ORIGIN")), + ]; + if (corsOrigins.length > 0) { + app.enableCors({ origin: corsOrigins, credentials: true }); + } + + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); + + // Self-documenting API: build an OpenAPI document from the @nestjs/swagger + // decorators emitted on the controllers/DTOs, then serve an interactive Scalar + // API reference at /docs. Scalar renders straight from the in-memory document + // (no extra network hop). Point the reference's "try it" requests at this + // server; in dev, allow its origin via DOCS_CORS_ORIGIN (above). + const openApiConfig = new DocumentBuilder() + .setTitle("API") + .setVersion("1.0.0") + .addBearerAuth() + .build(); + const openApiDocument = SwaggerModule.createDocument(app, openApiConfig); + app.use("/docs", apiReference({ content: openApiDocument })); + + // L1: on SIGTERM/SIGINT, let Nest lifecycle hooks (onModuleDestroy / clean + // shutdown of the TypeORM pool) run -> graceful shutdown. + app.enableShutdownHooks(); + + const port = config.get("PORT") ?? 3000; + await app.listen(port); +} +void bootstrap();`; + +/* ── test/app.e2e-spec.ts (SMOKE E2E — H6) ───────────────────────────────── + * AppModule'u gercekten boot eder + bir HTTP istegi atar. Bilinmeyen bir rota + * 404 doner (Nest varsayilan + AllExceptionsFilter zarfi); bu, uygulamanin DI + * grafiginin tam cozuldugunu ve filter'in baglandigini KANITLAR. Strict altinda + * derlenir. Calistirmak icin DATABASE_URL gerekir (TypeORM forRootAsync). */ +const APP_E2E_SPEC_TS = `import { INestApplication, ValidationPipe } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import request from "supertest"; +import { AppModule } from "../src/app.module"; + +describe("AppModule (e2e smoke)", () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("boots and returns a consistent 404 envelope for an unknown route", async () => { + const res = await request(app.getHttpServer()).get("/__solarch_healthcheck__"); + expect(res.status).toBe(404); + expect(res.body).toHaveProperty("statusCode", 404); + expect(res.body).toHaveProperty("timestamp"); + }); +});`; + +const README_MD = `# solarch-generated + +This project was deterministically generated from a TechnicalGraph by the Solarch +Constructor. Method bodies are marked with \`@solarch:surgical\` markers; bodies that +throw \`NOT_IMPLEMENTED\` are the algorithm areas (filled in by Surgical AI or a +developer). + +## Architecture + +- \`src/core/core.module.ts\` — core infrastructure: Config + Pino logger + TypeORM + + (depending on the graph) Cache/Queue/Schedule/Events + global exception filter. \`AppModule\` + imports only this, plus \`CommonModule\` (if present) and the feature modules. +- \`src/shared/\` — cross-cutting primitives. \`filters/all-exceptions.filter.ts\` + (global exception filter) is always present. \`guards/auth.guard.ts\` (a real JWT + guard: verifies the Bearer token against JWT_SECRET and populates request.user) and + \`decorators/current-user.decorator.ts\` (the \`@CurrentUser()\` param decorator plus + \`AuthUser\`/\`AuthResponse\` types) are generated when an endpoint requires + authentication or returns a login token, and \`guards/roles.guard.ts\` + + \`decorators/roles.decorator.ts\` (RolesGuard reads the @Roles metadata and enforces + it) only when an endpoint declares required roles. +- \`src//\` — per feature: module + controller + service + repository + + entity/dto/exception. +- \`src/migrations/\` — a TypeORM TS migration class per table/view (numbered by FK + dependency order). The raw SQL under \`migrations/\` is for reference and readability; + the TS migrations are the ones that actually run. + +## Filling in surgical areas + +The body immediately below each \`// @solarch:surgical id=#\` comment +throws \`throw new Error("NOT_IMPLEMENTED: ...")\`. Fill in only that marked region; +do NOT change the signature, decorators, or file structure — the next generation +produces a byte-identical skeleton from the same graph, and any hand-written signature +change would be lost. The \`throws:\` and \`deps:\` hints on the marker line list the +available exceptions and DI dependencies. + +## Running + +\`\`\`bash +pnpm install +cp .env.example .env # replace the placeholders with real values +pnpm build +pnpm run db:migrate # apply the schema (TypeORM migration:run) +pnpm start +\`\`\` + +## Tests + +\`\`\`bash +pnpm test # unit (jest) +pnpm run test:e2e # smoke e2e (AppModule boot) +\`\`\` + +## Environment variables + +Copy \`.env.example\` to \`.env\`. Replace the \`\` placeholders with +real values; secret values are NEVER embedded in the generated code.`; diff --git a/apps/server/src/codegen/emitters/nestjs/service-spec.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/service-spec.emitter.spec.ts new file mode 100644 index 0000000..6ac20a2 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/service-spec.emitter.spec.ts @@ -0,0 +1,326 @@ +import { describe, it, expect } from "vitest"; +import { emitServiceSpecs } from "./service-spec.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; +import type { EdgeKind } from "../../../edges/schemas/edge.schema"; + +/* ── Fixture helpers (same shape as service.emitter.spec.ts) ───────── */ +const PROJECT = "00000000-0000-4000-8000-000000000000"; +const TAB = "22222222-2222-4222-8222-222222222222"; + +function node(type: StoredNode["type"], id: string, properties: Record): StoredNode { + return { + id, + type, + projectId: PROJECT, + positionX: 0, + positionY: 0, + homeTabId: TAB, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function edge(id: string, kind: EdgeKind, sourceNodeId: string, targetNodeId: string): StoredEdge { + return { + id, + projectId: PROJECT, + sourceNodeId, + targetNodeId, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: {}, + }; +} + +function ctxFrom(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { + return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; +} + +/* ── ID'ler ─────────────────────────────────────────────────────────────── */ +const SVC = "10000000-0000-4000-8000-000000000001"; +const REPO = "10000000-0000-4000-8000-000000000002"; +const DEP_SVC = "10000000-0000-4000-8000-000000000003"; + +/* ── Node fixtures ──────────────────────────────────────────────────── */ +const usersRepository = node("Repository", REPO, { + RepositoryName: "UsersRepository", + EntityReference: "User", + CustomQueries: [ + { QueryName: "findByEmail", Parameters: [], ReturnType: "User" }, + { QueryName: "countActive", Parameters: [], ReturnType: "number" }, + ], +}); + +const paymentService = node("Service", DEP_SVC, { + ServiceName: "PaymentService", + Description: "Payment side service", + IsTransactionScoped: false, + Dependencies: [], + Methods: [ + { MethodName: "charge", Visibility: "public", Parameters: [], ReturnType: "void", IsAsync: true }, + { MethodName: "internalHelper", Visibility: "private", Parameters: [], ReturnType: "void", IsAsync: false }, + ], +}); + +const usersService = node("Service", SVC, { + ServiceName: "UsersService", + Description: "User business logic", + IsTransactionScoped: true, + Dependencies: [ + { Kind: "Repository", Ref: "UsersRepository" }, + { Kind: "Service", Ref: "PaymentService" }, + ], + Methods: [ + { + MethodName: "createUser", + Visibility: "public", + Parameters: [{ Name: "input", Type: "unknown", Optional: false, DtoRef: "CreateUserDto" }], + ReturnType: "User", + ReturnDtoRef: "UserDto", + IsAsync: true, + Throws: [], + Description: "Creates a new user.", + }, + { + MethodName: "validateNow", + Visibility: "public", + Parameters: [], + ReturnType: "boolean", + IsAsync: false, + Throws: [], + }, + { + MethodName: "secretInternal", + Visibility: "private", + Parameters: [], + ReturnType: "void", + IsAsync: false, + Throws: [], + }, + ], +}); + +const fullNodes = [usersService, usersRepository, paymentService]; + +describe("emitServiceSpecs", () => { + it("tam davranis iskeleti — snapshot (mock provider'lar + per-metot delegasyon TODO)", () => { + const ctx = ctxFrom(fullNodes, []); + const files = emitServiceSpecs(ctx); + // Iki Service var (UsersService + PaymentService) -> iki spec. + const usersSpec = files.find((f) => f.path === "users/users.service.spec.ts"); + expect(usersSpec).toBeDefined(); + expect(usersSpec!.content).toMatchInlineSnapshot(` + "import { Test, TestingModule } from "@nestjs/testing"; + import { PaymentService } from "../payment/payment.service"; + import { UsersRepository } from "./users.repository"; + import { UsersService } from "./users.service"; + + /** Behavior test skeleton for UsersService (Solarch-generated). */ + describe("UsersService", () => { + let usersService: UsersService; + + // Mocked dependencies — delegated methods are jest.fn() so calls can be asserted. + const paymentService = { charge: jest.fn() }; + const usersRepository = { countActive: jest.fn(), findByEmail: jest.fn() }; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { provide: PaymentService, useValue: paymentService as unknown as PaymentService }, + { provide: UsersRepository, useValue: usersRepository as unknown as UsersRepository }, + ], + }).compile(); + + usersService = moduleRef.get(UsersService); + }); + + it("is defined (DI resolves)", () => { + expect(usersService).toBeDefined(); + }); + + describe("createUser", () => { + // Behavior skeleton — un-skip and replace the comments with real + // arrange/act/assert once you've reviewed the filled method body. + it.skip("delegates to its dependencies", () => { + // Arrange: stub the calls this method should delegate to, e.g. + // paymentService.charge.mockResolvedValue(undefined as never); + // usersRepository.countActive.mockResolvedValue(undefined as never); + // usersRepository.findByEmail.mockResolvedValue(undefined as never); + // Act: + // const result = await usersService.createUser(/* input */); + // Assert: replace with real delegation/return assertions, e.g. + // expect(paymentService.charge).toHaveBeenCalled(); + // expect(usersRepository.countActive).toHaveBeenCalled(); + // expect(usersRepository.findByEmail).toHaveBeenCalled(); + }); + }); + + describe("validateNow", () => { + // Behavior skeleton — un-skip and replace the comments with real + // arrange/act/assert once you've reviewed the filled method body. + it.skip("delegates to its dependencies", () => { + // Arrange: stub the calls this method should delegate to, e.g. + // paymentService.charge.mockResolvedValue(undefined as never); + // usersRepository.countActive.mockResolvedValue(undefined as never); + // usersRepository.findByEmail.mockResolvedValue(undefined as never); + // Act: + // const result = usersService.validateNow(); + // Assert: replace with real delegation/return assertions, e.g. + // expect(paymentService.charge).toHaveBeenCalled(); + // expect(usersRepository.countActive).toHaveBeenCalled(); + // expect(usersRepository.findByEmail).toHaveBeenCalled(); + }); + }); + }); + " + `); + }); + + it("yalniz PUBLIC metotlar test edilir (private/protected dis API degil)", () => { + const ctx = ctxFrom(fullNodes, []); + const usersSpec = emitServiceSpecs(ctx).find((f) => f.path === "users/users.service.spec.ts")!; + expect(usersSpec.content).toContain('describe("createUser", () =>'); + expect(usersSpec.content).toContain('describe("validateNow", () =>'); + // private metot icin ne describe ne cagri uretilir. + expect(usersSpec.content).not.toContain('describe("secretInternal"'); + expect(usersSpec.content).not.toContain("secretInternal("); + }); + + it("davranis iskeleti: her public metot bir it.skip blogu (bayat stub assert'i NONE)", () => { + const ctx = ctxFrom(fullNodes, []); + const usersSpec = emitServiceSpecs(ctx).find((f) => f.path === "users/users.service.spec.ts")!; + // Her metot ATLANMIS iskelet (it.skip) -> dolu metot jest'i KIRMAZ. + expect(usersSpec.content).toContain('it.skip("delegates to its dependencies", () => {'); + // act ipucu yorum olarak: async metot -> await; sync -> await yok. + expect(usersSpec.content).toContain("// const result = await usersService.createUser(/* input */);"); + expect(usersSpec.content).toContain("// const result = usersService.validateNow();"); + // Eski stub-sozlesmesi assert'i KALMADI (metot dolunca bayatlayip fail ederdi). + expect(usersSpec.content).not.toContain("NOT_IMPLEMENTED"); + expect(usersSpec.content).not.toContain(".rejects.toThrow"); + // Tek AKTIF assert: DI-resolves smoke'undaki toBeDefined. + const definedOnly = usersSpec.content.split("toBeDefined").length - 1; + expect(definedOnly).toBe(1); + }); + + it("delegasyon iskeleti: her mock metodu icin arrange + assert ipucu (yorum)", () => { + const ctx = ctxFrom(fullNodes, []); + const usersSpec = emitServiceSpecs(ctx).find((f) => f.path === "users/users.service.spec.ts")!; + // Arrange ipucu (mockResolvedValue) + assert ipucu (toHaveBeenCalled) yorum olarak. + expect(usersSpec.content).toContain("// usersRepository.findByEmail.mockResolvedValue(undefined as never);"); + expect(usersSpec.content).toContain("// expect(usersRepository.findByEmail).toHaveBeenCalled();"); + expect(usersSpec.content).toContain("// expect(paymentService.charge).toHaveBeenCalled();"); + expect(usersSpec.content).toContain("// Behavior skeleton — un-skip"); + }); + + it("mock provider'lar gercek public metot adlarindan kurulur (Service.Methods / Repository.CustomQueries)", () => { + const ctx = ctxFrom(fullNodes, []); + const usersSpec = emitServiceSpecs(ctx).find((f) => f.path === "users/users.service.spec.ts")!; + // Repository mock'u CustomQueries'ten (countActive, findByEmail), isme sirali. + expect(usersSpec.content).toContain("const usersRepository = { countActive: jest.fn(), findByEmail: jest.fn() };"); + // Service mock'u yalniz PUBLIC metottan (charge; private internalHelper NOT). + expect(usersSpec.content).toContain("const paymentService = { charge: jest.fn() };"); + expect(usersSpec.content).not.toContain("internalHelper"); + // useValue, sinif tipine cast'lenir (strict altinda DI tip-uyumu). + expect(usersSpec.content).toContain( + "{ provide: UsersRepository, useValue: usersRepository as unknown as UsersRepository },", + ); + }); + + it("DI = Dependencies ∪ CALLS hedefleri, DEDUP (ayni repo iki yoldan -> tek mock)", () => { + // Dependencies'te UsersRepository + CALLS edge ile de ayni repo -> tek mock alani. + const ctx = ctxFrom(fullNodes, [edge("e-dup", "CALLS", SVC, REPO)]); + const usersSpec = emitServiceSpecs(ctx).find((f) => f.path === "users/users.service.spec.ts")!; + const mockDecls = usersSpec.content.split("const usersRepository = {").length - 1; + expect(mockDecls).toBe(1); + const providerLines = usersSpec.content.split("{ provide: UsersRepository,").length - 1; + expect(providerLines).toBe(1); + }); + + it("bagimliliksiz servis: constructor mock yok, providers tek satir, davranis blogu yine uretilir", () => { + const lonely = node("Service", SVC, { + ServiceName: "LonelyService", + Description: "No deps", + IsTransactionScoped: false, + Dependencies: [], + Methods: [ + { MethodName: "ping", Visibility: "public", Parameters: [], ReturnType: "boolean", IsAsync: false, Throws: [] }, + ], + }); + const ctx = ctxFrom([lonely], []); + const [spec] = emitServiceSpecs(ctx); + expect(spec.path).toBe("lonely/lonely.service.spec.ts"); + // Bos DI -> mock blogu ve jest.clearAllMocks NONE; providers tek satir. + expect(spec.content).not.toContain("Mocked dependencies"); + expect(spec.content).not.toContain("jest.clearAllMocks();"); + expect(spec.content).toContain("providers: [LonelyService],"); + // Davranis iskeleti yine var (atlanmis it.skip; mock'suz da uretilir). + expect(spec.content).toContain('describe("ping", () =>'); + expect(spec.content).toContain('it.skip("delegates to its dependencies", () => {'); + expect(spec.content).toContain("// const result = lonelyService.ping();"); + // Mock yokken arrange ipucu placeholder'a duser. + expect(spec.content).toContain(""); + }); + + it("metotsuz servis: davranis blogu yok ama DI-resolves smoke korunur", () => { + // Sema Methods.min(1) ister ama emitter THROW etmemeli; bos Methods'a dayanikli. + const empty = node("Service", SVC, { + ServiceName: "EmptyService", + Description: "No methods", + IsTransactionScoped: false, + Dependencies: [], + Methods: [], + }); + const ctx = ctxFrom([empty], []); + const [spec] = emitServiceSpecs(ctx); + expect(spec.content).toContain('it("is defined (DI resolves)"'); + expect(spec.content).not.toContain("delegates to its dependencies"); + }); + + it("cozulemeyen bagimlilik ref'i ATLANIR (mock uretilmez -> spec derlenebilir kalir)", () => { + const svc = node("Service", SVC, { + ServiceName: "GhostUserService", + Description: "Has an unresolvable dep", + IsTransactionScoped: false, + Dependencies: [{ Kind: "Repository", Ref: "MissingRepository" }], + Methods: [ + { MethodName: "run", Visibility: "public", Parameters: [], ReturnType: "void", IsAsync: true, Throws: [] }, + ], + }); + const ctx = ctxFrom([svc], []); + const [spec] = emitServiceSpecs(ctx); + // Cozulemeyen ref import edilmez/mocklanmaz. + expect(spec.content).not.toContain("MissingRepository"); + // Davranis blogu yine uretilir. + expect(spec.content).toContain('describe("run", () =>'); + }); + + it("content ends with single newline", () => { + const ctx = ctxFrom(fullNodes, []); + const usersSpec = emitServiceSpecs(ctx).find((f) => f.path === "users/users.service.spec.ts")!; + expect(usersSpec.content.endsWith("});\n")).toBe(true); + expect(usersSpec.content.endsWith("});\n\n")).toBe(false); + }); + + it("test dosyalari surgical marker TASIMAZ ve nodeId tasimaz", () => { + const ctx = ctxFrom(fullNodes, []); + for (const f of emitServiceSpecs(ctx)) { + expect(f.surgicalMarkers).toBe(0); + expect(f.nodeId).toBeUndefined(); + expect(f.language).toBe("typescript"); + } + }); + + it("DETERMINISM: two independent graph builds -> byte-identical", () => { + const a = emitServiceSpecs(ctxFrom(fullNodes, [edge("e", "CALLS", SVC, REPO)])); + const b = emitServiceSpecs(ctxFrom(fullNodes, [edge("e", "CALLS", SVC, REPO)])); + expect(a.map((f) => f.content)).toEqual(b.map((f) => f.content)); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/service-spec.emitter.ts b/apps/server/src/codegen/emitters/nestjs/service-spec.emitter.ts new file mode 100644 index 0000000..3dee820 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/service-spec.emitter.ts @@ -0,0 +1,289 @@ +import type { EmitterContext, GeneratedFile } from "../../types"; +import { propsOf, type CodeGraph, type CodeNode } from "../../ir"; +import { + filePathFor, + importPathOf, + pascalCase, + camelCase, + relativeImportPath, +} from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers } from "../../surgical"; + +/* ──────────────────────────────────────────────────────────────────────── + * service-spec.emitter.ts — Service node basina bir Jest DAVRANIS testi + * ISKELETI uretir (#11): /.service.spec.ts, service dosyasinin + * HEMEN yaninda. + * + * NEDEN davranis iskeleti: eski iskelet yalniz "DI resolves" smoke testi yaziyordu + * ("servis var" der ama "siparis olusturur" demez -> sahte guven). Bu emitter + * graph'taki Methods + Dependencies'ten her PUBLIC metot icin bir davranis + * iskeleti cikarir: + * - Test.createTestingModule, gercek service'i + her bagimliligi MOCK provider + * ile kurar (her mock'un metotlari jest.fn() -> delegasyon assert edilebilir). + * - DI-resolves smoke testi AKTIF kalir (`it(...)` -> gercek regresyon korumasi). + * - Her public metot icin ATLANMIS (`it.skip`) bir davranis iskeleti uretilir: + * arrange/act/assert ipuclari yorum olarak birakilir. ATLANDIGI icin govde stub + * da olsa dolu da olsa jest'i KIRMAZ — eski surum "NOT_IMPLEMENTED throw eder" + * diye assert ediyordu, ama surgical metot dolunca bu assert bayatlayip fail + * ediyordu (kod dogru, test eski stub'i olcuyor). Dogru sozlesmeyi codegen aninda + * bilemeyiz (govde henuz yazilmadi) -> iskelet. Gelistirici un-skip edip gercek + * assert'leri yazar (or. `expect(orderRepository.save).toHaveBeenCalled()`). + * + * SAF + DETERMINISTIC: bagimliliklar/metotlar isme gore sirali, import'lar + * ImportCollector ile, icerik tek "\n" ile biter, timestamp/random NONE. Node'a + * bagli NOT (test dosyasi; GeneratedFile.nodeId tasimaz). + * ──────────────────────────────────────────────────────────────────────── */ + +/** Service node'larindan davranis test iskeletleri uretir (her servis bir spec). */ +export function emitServiceSpecs(ctx: EmitterContext): GeneratedFile[] { + const out: GeneratedFile[] = []; + for (const svc of ctx.graph.allOf("Service")) { + const f = buildServiceSpec(svc, ctx); + if (f) out.push(f); + } + return out; +} + +function buildServiceSpec(node: CodeNode, ctx: EmitterContext): GeneratedFile | null { + const className = pascalCase(node.name); + if (className.length === 0) return null; + const servicePath = filePathFor(node, ctx.graph); + // /.service.ts -> /.service.spec.ts. + const specPath = servicePath.replace(/\.ts$/, ".spec.ts"); + const instanceName = camelCase(node.name) || "service"; + + const imports = new ImportCollector(); + imports.add("Test", "@nestjs/testing"); + imports.add("TestingModule", "@nestjs/testing"); + imports.add(className, importPathOf(relativeImportPath(specPath, servicePath))); + + // ── DI bagimliliklari -> jest mock provider'lari (gercek DB/Redis gerektirmez) ── + // service.emitter ile ayni kume: Dependencies ∪ CALLS hedefleri (injectable). + const deps = collectInjectedDeps(node, ctx); + for (const dep of deps) { + imports.add(dep.className, importPathOf(relativeImportPath(specPath, dep.filePath))); + } + + // ── Yalniz PUBLIC metotlar test edilir (private/protected dis API degil) ── + const methods = [...(propsOf<"Service">(node).Methods ?? [])] + .filter((m) => (m.Visibility ?? "public") === "public") + .sort((a, b) => cmp(a.MethodName, b.MethodName)); + + const lines: string[] = []; + lines.push(`/** Behavior test skeleton for ${className} (Solarch-generated). */`); + lines.push(`describe("${className}", () => {`); + lines.push(` let ${instanceName}: ${className};`); + + // ── Mock bagimliliklar (jest.fn() metotlariyla — delegasyon assert edilebilir) ── + // Her mock, bagimliligin GERCEK public metot adlarindan (Service.Methods / + // Repository.CustomQueries) kurulur; bilinmeyen yuzeyler bos `{}` ile kalir + // (DI yine cozulur). `as unknown as ` ile useValue tip-uyumlu saglanir. + if (deps.length > 0) { + lines.push(""); + lines.push(" // Mocked dependencies — delegated methods are jest.fn() so calls can be asserted."); + for (const dep of deps) { + lines.push(` const ${dep.field} = ${renderMockObject(dep.methodNames)};`); + } + } + + lines.push(""); + lines.push(" beforeEach(async () => {"); + if (deps.length > 0) { + lines.push(" jest.clearAllMocks();"); + } + lines.push(" const moduleRef: TestingModule = await Test.createTestingModule({"); + if (deps.length > 0) { + lines.push(" providers: ["); + lines.push(` ${className},`); + for (const dep of deps) { + lines.push(` { provide: ${dep.className}, useValue: ${dep.field} as unknown as ${dep.className} },`); + } + lines.push(" ],"); + } else { + lines.push(` providers: [${className}],`); + } + lines.push(" }).compile();"); + lines.push(""); + lines.push(` ${instanceName} = moduleRef.get<${className}>(${className});`); + lines.push(" });"); + + // ── Smoke: DI cozuluyor (regression korumasi; tek basina yeterli NOT) ── + lines.push(""); + lines.push(' it("is defined (DI resolves)", () => {'); + lines.push(` expect(${instanceName}).toBeDefined();`); + lines.push(" });"); + + // ── Her public metot icin DAVRANIS iskeleti ────────────────────────────── + for (const m of methods) { + lines.push(""); + lines.push(...renderMethodBehavior(instanceName, m, deps)); + } + + lines.push("});"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + return { + path: specPath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; +} + +/* ── Davranis blogu: bir public metot icin ATLANMIS (it.skip) iskelet ────────── + * + * NEDEN it.skip (assert NOT): eski iskelet "metot NOT_IMPLEMENTED throw eder" + * diye assert ediyordu — bu yalniz STUB icin gecerli. Surgical metot DOLUNCA govde + * gercek davranisi yapar (artik NOT_IMPLEMENTED throw etmez) → assert BAYATLAR → + * jest kirilir (kod dogru, test eski hâli olcuyor). Dogru sozlesmeyi codegen aninda + * bilemeyiz (govde henuz yazilmadi), o yuzden bolge bir ISKELET'tir: `it.skip` ile + * atlanir (jest "skipped" der, fail ETMEZ — ne stub ne dolu hâlde) + arrange/act/ + * assert ipuclari yorum olarak birakilir. Gelistirici un-skip edip gercek assert'leri + * yazar. DI-resolves smoke testi (`it(...)`) AKTIF kalir (gercek regresyon korumasi). */ +function renderMethodBehavior( + instanceName: string, + method: ServiceMethod, + deps: ResolvedDep[], +): string[] { + const name = method.MethodName; + const isAsync = method.IsAsync === true; + const args = (method.Parameters ?? []).map((p) => `/* ${p.Name} */`).join(", "); + const awaitKw = isAsync ? "await " : ""; + + const out: string[] = []; + out.push(` describe("${name}", () => {`); + // Atlanan iskelet: govde stub da olsa dolu da olsa fail etmez. Un-skip + gercek assert. + out.push(" // Behavior skeleton — un-skip and replace the comments with real"); + out.push(" // arrange/act/assert once you've reviewed the filled method body."); + out.push(` it.skip("delegates to its dependencies", () => {`); + // Arrange (yorum): mocklanan bagimliliklarin metotlarini stub'la. + out.push(" // Arrange: stub the calls this method should delegate to, e.g."); + const arrangeHints = delegationHints(deps); + if (arrangeHints.length > 0) { + for (const h of arrangeHints) out.push(` // ${h}`); + } else { + out.push(" // "); + } + // Act (yorum): gercek argumanlarla cagir. + out.push(" // Act:"); + out.push(` // const result = ${awaitKw}${instanceName}.${name}(${args});`); + // Assert (yorum): gercek delegasyon/donus beklentileri. + out.push(" // Assert: replace with real delegation/return assertions, e.g."); + const assertHints = assertionHints(deps); + if (assertHints.length > 0) { + for (const h of assertHints) out.push(` // ${h}`); + } else { + out.push(" // expect(result).toEqual(/* expected */);"); + } + out.push(" });"); + out.push(" });"); + return out; +} + +/** Arrange iskeleti satirlari: her cozulen bagimliligin her mock metodu icin bir + * `dep.method.mockResolvedValue(... as never);` ipucu (yorum). Determinizm: + * deps + methodNames zaten sirali. */ +function delegationHints(deps: ResolvedDep[]): string[] { + const out: string[] = []; + for (const dep of deps) { + for (const mn of dep.methodNames) { + out.push(`${dep.field}.${mn}.mockResolvedValue(undefined as never);`); + } + } + return out; +} + +/** Assert iskeleti satirlari: her mock metodu icin bir + * `expect(dep.method).toHaveBeenCalled();` ipucu (yorum). */ +function assertionHints(deps: ResolvedDep[]): string[] { + const out: string[] = []; + for (const dep of deps) { + for (const mn of dep.methodNames) { + out.push(`expect(${dep.field}.${mn}).toHaveBeenCalled();`); + } + } + return out; +} + +/** Bir mock nesne literali uretir: bilinen metot adlari -> jest.fn(); hic metot + * yoksa bos `{}` (DI yine cozulur, yuzey bilinmiyor). */ +function renderMockObject(methodNames: string[]): string { + if (methodNames.length === 0) return "{}"; + return `{ ${methodNames.map((m) => `${m}: jest.fn()`).join(", ")} }`; +} + +interface ResolvedDep { + /** constructor alan adi = camelCase(name) (service.emitter ile ayni). */ + field: string; + /** enjekte edilen sinif adi = pascalCase(name). */ + className: string; + /** cozulen node'un dosya yolu (import icin). */ + filePath: string; + /** mock'lanacak public metot adlari (Service.Methods / Repository.CustomQueries), + * isme gore sirali; bilinmiyorsa bos. */ + methodNames: string[]; +} + +/** Servisin enjekte ettigi (mock'lanacak) provider'lari cozer: property + * Dependencies ∪ CALLS edge hedefleri (Repository/Service/Cache/ExternalService), + * yalniz COZULEBILEN + TAM emitter'li olanlar (sinif adi pascalCase(name)). + * Cozulemeyen/stub ref'ler atlanir (mock uretmeyiz -> spec derlenebilir kalsin). + * Her dep icin public metot adlari (delegasyon mock'u) cikarilir. + * DEDUP + alan adina gore sirali (deterministik; service.emitter ile ayni sira). */ +function collectInjectedDeps(node: CodeNode, ctx: EmitterContext): ResolvedDep[] { + const graph = ctx.graph; + const byId = new Map(); + const FULL: ReadonlySet = new Set(["Repository", "Service", "Cache", "ExternalService"]); + + const consider = (target: CodeNode | null): void => { + if (!target) return; + if (!FULL.has(target.kindOf())) return; + if (target.id === node.id) return; + byId.set(target.id, { + field: camelCase(target.name), + className: pascalCase(target.name), + filePath: filePathFor(target, graph), + methodNames: publicMethodNamesOf(target), + }); + }; + + const props = node.properties as { Dependencies?: { Kind: string; Ref: string }[] }; + for (const dep of props.Dependencies ?? []) { + consider(graph.resolveRef(dep.Kind as never, dep.Ref)); + } + for (const e of graph.outEdges(node.id, "CALLS")) { + consider(graph.byId(e.targetNodeId)); + } + + return [...byId.values()].sort((a, b) => cmp(a.field, b.field)); +} + +/** Bir bagimlilik node'unun dis API'sini olusturan public metot adlari (mock'a + * jest.fn() olarak konur). Determinizm: isme gore sirali + DEDUP. + * - Service -> public Methods (Visibility public/default). + * - Repository -> CustomQueries (uretilen repository sinifinin public yuzeyi). + * - Cache/ExternalService -> bilinmeyen yuzey (emitter'a kuplaj yok) -> bos. + * Bossa mock `{}` olur (DI yine cozulur). */ +function publicMethodNamesOf(dep: CodeNode): string[] { + const names = new Set(); + if (dep.kindOf() === "Service") { + for (const m of propsOf<"Service">(dep).Methods ?? []) { + if ((m.Visibility ?? "public") === "public") names.add(m.MethodName); + } + } else if (dep.kindOf() === "Repository") { + const queries = (dep.properties as { CustomQueries?: { QueryName: string }[] }).CustomQueries ?? []; + for (const q of queries) names.add(q.QueryName); + } + return [...names].sort(cmp); +} + +/** Deterministik string karsilastirmasi (service.emitter ile ayni). */ +function cmp(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/* ── Yerel tip: ServiceMethod (service.schema.ts ile ayni shape) ──────────── */ +type ServiceMethod = NonNullable>["Methods"]>[number]; diff --git a/apps/server/src/codegen/emitters/nestjs/service.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/service.emitter.spec.ts new file mode 100644 index 0000000..17de0aa --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/service.emitter.spec.ts @@ -0,0 +1,616 @@ +import { describe, it, expect } from "vitest"; +import { emitService } from "./service.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; +import type { EdgeKind } from "../../../edges/schemas/edge.schema"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +const PROJECT = "00000000-0000-4000-8000-000000000000"; +const TAB = "22222222-2222-4222-8222-222222222222"; + +function node(type: StoredNode["type"], id: string, properties: Record): StoredNode { + return { + id, + type, + projectId: PROJECT, + positionX: 0, + positionY: 0, + homeTabId: TAB, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function edge(id: string, kind: EdgeKind, sourceNodeId: string, targetNodeId: string): StoredEdge { + return { + id, + projectId: PROJECT, + sourceNodeId, + targetNodeId, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: false }, + }; +} + +function ctxFrom(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { + return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; +} + +/* ── ID'ler ─────────────────────────────────────────────────────────────── */ +const SVC = "10000000-0000-4000-8000-000000000001"; +const REPO = "10000000-0000-4000-8000-000000000002"; +const DTO_CREATE = "10000000-0000-4000-8000-000000000003"; +const DTO_USER = "10000000-0000-4000-8000-000000000004"; +const EXC = "10000000-0000-4000-8000-000000000005"; +const CACHE = "10000000-0000-4000-8000-000000000006"; + +/* ── Node fixtures ──────────────────────────────────────────────────── */ +const usersRepository = node("Repository", REPO, { + RepositoryName: "UsersRepository", + EntityReference: "User", + CustomQueries: [], +}); + +const createUserDto = node("DTO", DTO_CREATE, { + Name: "CreateUserDto", + Description: "User olusturma girdisi", + Fields: [{ Name: "email", DataType: "string", IsRequired: true, IsArray: false }], +}); + +const userDto = node("DTO", DTO_USER, { + Name: "UserDto", + Description: "User ciktisi", + Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], +}); + +const notFoundException = node("Exception", EXC, { + ExceptionName: "UserNotFoundException", + Description: "User bulunamadi", + HttpStatusCode: 404, + LogSeverity: "Warning", +}); + +const usersCache = node("Cache", CACHE, { + CacheName: "UsersCache", + // Cache semasi v1'de stub; emitter sadece adi/yolu kullanir. +}); + +const usersService = node("Service", SVC, { + ServiceName: "UsersService", + Description: "User is mantigi", + IsTransactionScoped: true, + Dependencies: [{ Kind: "Repository", Ref: "UsersRepository" }], + Methods: [ + { + MethodName: "createUser", + Visibility: "public", + Parameters: [{ Name: "input", Type: "unknown", Optional: false, DtoRef: "CreateUserDto" }], + ReturnType: "User", + ReturnDtoRef: "UserDto", + IsAsync: true, + Throws: ["UserNotFoundException"], + Description: "Yeni kullanici olusturur ve UserDto doner.", + }, + { + MethodName: "countActive", + Visibility: "private", + Parameters: [{ Name: "since", Type: "Date", Optional: true, Default: "new Date()" }], + ReturnType: "number", + IsAsync: false, + Throws: [], + }, + ], +}); + +describe("emitService", () => { + it("tam servis — snapshot (DI, dekorator, DTO import, surgical marker)", () => { + const ctx = ctxFrom( + [usersService, usersRepository, createUserDto, userDto, notFoundException, usersCache], + [edge("e-cache", "CALLS", SVC, CACHE)], + ); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { Injectable } from "@nestjs/common"; + import { UserNotFoundException } from "../common/exceptions/user-not-found.exception"; + import { CreateUserDto } from "./dto/create-user.dto"; + import { UserDto } from "./dto/user.dto"; + import { UsersCache } from "./users.cache"; + import { UsersRepository } from "./users.repository"; + + /** User is mantigi */ + @Injectable() + export class UsersService { + constructor( + private readonly usersCache: UsersCache, + private readonly usersRepository: UsersRepository, + ) {} + + private countActive(since: Date = new Date()): number { + // @solarch:surgical id=10000000-0000-4000-8000-000000000001#countActive + // deps: this.usersCache, this.usersRepository + throw new Error("NOT_IMPLEMENTED: UsersService.countActive"); + } + + async createUser(input: CreateUserDto): Promise { + // @solarch:surgical id=10000000-0000-4000-8000-000000000001#createUser + // Yeni kullanici olusturur ve UserDto doner. + // throws: UserNotFoundException + // deps: this.usersCache, this.usersRepository + throw new Error("NOT_IMPLEMENTED: UsersService.createUser"); + } + } + ", + "language": "typescript", + "path": "users/users.service.ts", + "surgicalMarkers": 2, + } + `); + }); + + it("public metot IsAsync:false olsa bile async (await-sync TS1308 onle); private sync KALIR", () => { + // Gercek bug: AuthService.ValidateToken IsAsync:false ama fill await ister -> TS1308. + // Public metot daima async (NestJS idiom); private graf IsAsync'ini korur. + const svc = node("Service", SVC, { + ServiceName: "AuthService", + Description: "kimlik", + IsTransactionScoped: false, + Dependencies: [{ Kind: "Repository", Ref: "UsersRepository" }], + Methods: [ + { MethodName: "validateToken", Visibility: "public", Parameters: [{ Name: "token", Type: "string", Optional: false }], ReturnType: "UserDto", ReturnDtoRef: "UserDto", IsAsync: false, Throws: [] }, + { MethodName: "hashKey", Visibility: "private", Parameters: [{ Name: "raw", Type: "string", Optional: false }], ReturnType: "string", IsAsync: false, Throws: [] }, + ], + }); + const ctx = ctxFrom([svc, usersRepository, userDto], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + // public validateToken -> async + Promise<> + expect(file.content).toMatch(/async validateToken\([^)]*\): Promise/); + // private hashKey -> sync KALIR (async NOT) + expect(file.content).toMatch(/private hashKey\(raw: string\): string \{/); + expect(file.content).not.toMatch(/async hashKey/); + }); + + it("cozulemeyen DI bagimliligi constructor'a DANGLING tip basmaz (TS2304 + DI boot patlamasini onle)", () => { + // Bir Service, gercek bir node'a cozulmeyen bagimlilik bildirirse (or. Ref="Environment" + // ama oyle bir node yok) eskiden `private readonly environment: Environment` uretiliyordu: + // import yok -> TS2304, ayrica NestJS DI boot'ta "can't resolve" ile patlardi. Cozulemeyen + // dep DI'dan DUSURULMELI (cozulen repo kalir), yerine TODO. Uyari contract-lint Rule 5'te. + const svc = node("Service", SVC, { + ServiceName: "TokenService", + Description: "JWT uretir/dogrular", + IsTransactionScoped: false, + Dependencies: [ + { Kind: "Repository", Ref: "UsersRepository" }, // cozulur + { Kind: "Service", Ref: "Environment" }, // cozulmez (node yok) + ], + Methods: [ + { MethodName: "generateTokens", Visibility: "public", Parameters: [{ Name: "userId", Type: "UUID", Optional: false }], ReturnType: "TokenPair", IsAsync: false, Throws: [] }, + ], + }); + const ctx = ctxFrom([svc, usersRepository], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + // Dangling tip ve dangling DI alani URETILMEZ. + expect(file.content).not.toMatch(/:\s*Environment\b/); + expect(file.content).not.toContain("private readonly environment"); + // Cozulen repo bagimliligi KORUNUR. + expect(file.content).toContain("private readonly usersRepository: UsersRepository,"); + // Atlanan dep in-file gorunur (TODO). + expect(file.content).toMatch(/TODO:.*Environment.*(resolve|omitted)/i); + // Cozulemeyen serbest donus tipi (TokenPair) merkezi degrade ile Record olur (Fix 1). + expect(file.content).toContain("Record"); + }); + + it("DI = Dependencies ∪ CALLS hedefleri, DEDUP + isme gore sirali", () => { + // Dependencies'te UsersRepository var; CALLS edge ile de ayni repo → tek alan. + const ctx = ctxFrom( + [usersService, usersRepository, createUserDto, userDto, notFoundException], + [edge("e-dup", "CALLS", SVC, REPO)], + ); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + const occurrences = file.content.split("private readonly usersRepository").length - 1; + expect(occurrences).toBe(1); + expect(file.content).toContain("private readonly usersRepository: UsersRepository,"); + }); + + it("DTO import'lari DEGER import ile gelir (surgical AI runtime kullanir), exception deger import'u ile", () => { + const ctx = ctxFrom( + [usersService, usersRepository, createUserDto, userDto, notFoundException], + [], + ); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + // DTO'lar DEGER import ile: surgical AI govdede DTO'yu runtime deger olarak + // kullanabilsin (plainToInstance(CreateUserDto, ...)) -> `import type` olsaydi + // derlenmezdi. (DTO'lar UsersService ile ayni "users" feature'inda -> ./dto.) + expect(file.content).toContain('import { CreateUserDto } from "./dto/create-user.dto";'); + expect(file.content).toContain('import { UserDto } from "./dto/user.dto";'); + // type-only NOT -> "import type { ...Dto }" URETILMEMELI. + expect(file.content).not.toContain("import type { CreateUserDto }"); + expect(file.content).not.toContain("import type { UserDto }"); + // Exception deger import'u ile (THROWS kaynagi yok -> common/exceptions). + expect(file.content).toContain('import { UserNotFoundException } from "../common/exceptions/user-not-found.exception";'); + expect(file.content).toContain('import { Injectable } from "@nestjs/common";'); + }); + + it("her govde-gerektiren metot icin surgical marker + NOT_IMPLEMENTED", () => { + const ctx = ctxFrom([usersService, usersRepository, createUserDto, userDto, notFoundException], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + expect(file.surgicalMarkers).toBe(2); + expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: UsersService.createUser");'); + expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: UsersService.countActive");'); + }); + + it("async metot Promise<> sarar, sync metot sarmaz", () => { + const ctx = ctxFrom([usersService, usersRepository, createUserDto, userDto, notFoundException], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + expect(file.content).toContain("async createUser(input: CreateUserDto): Promise {"); + // Default varsa "?" duser (gecerli TS) → "since: Date = new Date()". + expect(file.content).toContain("private countActive(since: Date = new Date()): number {"); + }); + + it("content ends with single newline", () => { + const ctx = ctxFrom([usersService, usersRepository, createUserDto, userDto, notFoundException], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: two independent graph builds -> byte-identical", () => { + const nodes = [usersService, usersRepository, createUserDto, userDto, notFoundException, usersCache]; + const ctxA = ctxFrom(nodes, [edge("e-cache", "CALLS", SVC, CACHE)]); + const a = emitService(ctxA.graph.byId(SVC)!, ctxA)[0].content; + const ctxB = ctxFrom(nodes, [edge("e-cache", "CALLS", SVC, CACHE)]); + const b = emitService(ctxB.graph.byId(SVC)!, ctxB)[0].content; + expect(a).toBe(b); + }); + + it("DEDUP: cozulemeyen property Dependency, cozulebilen CALLS edge'ini MASKELEMEZ (import korunur)", () => { + // Property Dependency yanlis Kind ile cozulemez (Service diye arar ama node + // Repository'dir) -> ham ref "UsersRepository" filePath=null girer. Ayni ada + // giden CALLS edge gercek Repository'yi cozer. Cozulen KAZANMALI (import kalmali). + const svc = node("Service", SVC, { + ServiceName: "OrdersService", + Description: "Order mantigi", + IsTransactionScoped: false, + // Kasitli yanlis Kind -> resolveRef("Service","UsersRepository") = null. + Dependencies: [{ Kind: "Service", Ref: "UsersRepository" }], + Methods: [], + }); + const ctx = ctxFrom([svc, usersRepository], [edge("e-calls", "CALLS", SVC, REPO)]); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + // Tek alan (dedup), tip dogru ve EN ONEMLISI import uretildi (filePath null kalmadi). + const occurrences = file.content.split("private readonly usersRepository").length - 1; + expect(occurrences).toBe(1); + expect(file.content).toContain("private readonly usersRepository: UsersRepository,"); + // EN ONEMLISI: import uretildi (cozulen entry kazandi; filePath null kalmadi). + expect(file.content).toMatch(/import \{ UsersRepository \} from ".*users\.repository"/); + }); + + /* ── DIZI RETURN KORUMA: ReturnType="XDto[]" + ReturnDtoRef -> "XDto[]" ──── */ + it("dizi donus: ReturnType='XDto[]' + ReturnDtoRef -> Promise (controller ile hizali)", () => { + // Graf zaten dizi donusler icin ReturnType="CartItemDto[]" verir AMA DtoRef de + // doludur. Eskiden DtoRef dolu oldugunda ham Type atilip ciplak "CartItemDto" + // donerdi -> service tekil, controller dizi -> uyumsuz imza. Artik dizi korunur. + const cartItemDto = node("DTO", "10000000-0000-4000-8000-0000000000a1", { + Name: "CartItemDto", + Description: "Sepet kalemi ciktisi", + Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], + }); + const cartService = node("Service", SVC, { + ServiceName: "CartService", + Description: "Sepet is mantigi", + IsTransactionScoped: false, + Dependencies: [], + Methods: [ + { + MethodName: "getCart", + Visibility: "public", + Parameters: [{ Name: "userId", Type: "UUID", Optional: false }], + // CRITICAL: ham Type dizi tasir + DtoRef dolu. + ReturnType: "CartItemDto[]", + ReturnDtoRef: "CartItemDto", + IsAsync: true, + Throws: [], + Description: "Usernin sepet kalemlerini doner.", + }, + ], + }); + const ctx = ctxFrom([cartService, cartItemDto], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + // Dizi KORUNDU -> Promise (controller ile ayni imza). + expect(file.content).toContain("async getCart(userId: string): Promise {"); + expect(file.content).not.toContain("Promise {"); + // DTO yine DEGER import edilir (sinif adi cozuldu). + expect(file.content).toContain('import { CartItemDto } from "./dto/cart-item.dto";'); + }); + + it("dizi donus (DtoRef NONE): ReturnType='XDto[]' zaten korunur (regresyon)", () => { + // ReturnDtoRef bosken yol resolveTypeRef'ten gecer; dizi zaten korunuyordu. + // Bu testin amaci: duzeltme bu yolu BOZMADI (mevcut tekil davranis degismedi). + const productDto = node("DTO", "10000000-0000-4000-8000-0000000000b2", { + Name: "ProductDto", + Description: "Urun ciktisi", + Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], + }); + const productService = node("Service", SVC, { + ServiceName: "ProductService", + Description: "Urun is mantigi", + IsTransactionScoped: false, + Dependencies: [], + Methods: [ + { + MethodName: "list", + Visibility: "public", + Parameters: [], + ReturnType: "ProductDto[]", + // ReturnDtoRef NONE -> resolveTypeRef yolu. + IsAsync: true, + Throws: [], + }, + ], + }); + const ctx = ctxFrom([productService, productDto], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + expect(file.content).toContain("async list(): Promise {"); + }); + + it("dizi parametresi: Type='XDto[]' + DtoRef -> dizi korunur (param tarafi tutarli)", () => { + // Parametre tipinde de dizi-koruma tutarli olmali (gorev geregi). + const itemDto = node("DTO", "10000000-0000-4000-8000-0000000000c3", { + Name: "ItemDto", + Description: "Kalem girdisi", + Fields: [{ Name: "sku", DataType: "string", IsRequired: true, IsArray: false }], + }); + const bulkService = node("Service", SVC, { + ServiceName: "BulkService", + Description: "Toplu islem mantigi", + IsTransactionScoped: false, + Dependencies: [], + Methods: [ + { + MethodName: "addMany", + Visibility: "public", + Parameters: [{ Name: "items", Type: "ItemDto[]", Optional: false, DtoRef: "ItemDto" }], + ReturnType: "void", + IsAsync: true, + Throws: [], + }, + ], + }); + const ctx = ctxFrom([bulkService, itemDto], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + expect(file.content).toContain("async addMany(items: ItemDto[]): Promise {"); + expect(file.content).not.toContain("addMany(items: ItemDto)"); + }); + + /* ── TEK-SOURCE KARDINALITE: ReturnsCollection bildirilmis alani ────────── + * Graf SINGLE ReturnType verse bile (or. ListProducts'ta ReturnType='ProductDto', + * ReturnDtoRef='ProductDto'), operasyon bir COLLECTION ise service imzasi DTO[] + * olmali. Aksi halde controller dizi (route sezgisi) ↔ service tekil -> uyumsuz + * imza + surgical govdedeki `return result` (dizi) DERLEME hatasi verir (gercek + * bug: ListProducts/ListOrders, surgical-output 18 tsc hatasi). ReturnsCollection + * kardinalitenin TEK KAYNAGIDIR; emitter onu okur ve tipi DTO[]'e zorlar. */ + it("ReturnsCollection=true: tekil ReturnDtoRef'i Promise'e zorlar (tek-kaynak)", () => { + const productDto = node("DTO", "10000000-0000-4000-8000-0000000000d4", { + Name: "ProductDto", + Description: "Urun ciktisi", + Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], + }); + const catalogService = node("Service", SVC, { + ServiceName: "CatalogService", + Description: "Katalog is mantigi", + IsTransactionScoped: false, + Dependencies: [], + Methods: [ + { + MethodName: "listProducts", + Visibility: "public", + Parameters: [], + ReturnType: "ProductDto", // SINGLE ham tip + ReturnDtoRef: "ProductDto", + ReturnsCollection: true, // bildirilmis tek-kaynak + IsAsync: true, + Throws: [], + Description: "Urunleri listeler.", + }, + ], + }); + const ctx = ctxFrom([catalogService, productDto], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + expect(file.content).toContain("async listProducts(): Promise {"); + expect(file.content).not.toContain("Promise {"); + }); + + /* ── FALLBACK: metot-adi liste-semantigi (bildirilmis alan NONEKEN) ──────── + * Gercek bug: ListProducts/ListOrders graf'ta ReturnsCollection alani WITHOUT + + * tekil ReturnType ile geldi. Bildirilmis alan yoksa emitter, metot adinin liste- + * semantigine (list/all/search/findAll/findMany) bakip koleksiyon cikarir -> DTO[]. + * EXACT-kelime eslesmesi: "listen"/"getAllowance" gibi adlar YANLIS pozitif vermez. */ + it("fallback: liste-semantikli ad (listProducts) tekil ReturnType'i Promise yapar", () => { + const productDto = node("DTO", "10000000-0000-4000-8000-0000000000e5", { + Name: "ProductDto", + Description: "Urun ciktisi", + Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], + }); + const catalogService = node("Service", SVC, { + ServiceName: "CatalogService", + Description: "Katalog is mantigi", + IsTransactionScoped: false, + Dependencies: [], + Methods: [ + { + MethodName: "listProducts", + Visibility: "public", + Parameters: [], + ReturnType: "ProductDto", // SINGLE + ReturnsCollection NONE + ReturnDtoRef: "ProductDto", + IsAsync: true, + Throws: [], + Description: "Urunleri listeler.", + }, + ], + }); + const ctx = ctxFrom([catalogService, productDto], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + expect(file.content).toContain("async listProducts(): Promise {"); + }); + + /* ── PRECEDENCE: bildirilmis ReturnsCollection=false, ad-sezgisini OVERRIDES ─── + * 'getAllSettings' adi 'all' icerir -> fallback koleksiyon derdi; ama alan acikca + * false. Bildirilmis alan KAZANIR (tekil kalir). Bu, `??` semantigini kilitler: + * `||` kullanilsaydi false dusup ada kayardi (ince regresyon) -> bu test yakalar. */ + it("ReturnsCollection=false ad-sezgisini ezer (bildirilen > tahmin)", () => { + const settingsDto = node("DTO", "10000000-0000-4000-8000-0000000000f6", { + Name: "SettingsDto", + Description: "Ayar ciktisi", + Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], + }); + const settingsService = node("Service", SVC, { + ServiceName: "SettingsService", + Description: "Ayar is mantigi", + IsTransactionScoped: false, + Dependencies: [], + Methods: [ + { + MethodName: "getAllSettings", // 'all' -> ad-sezgisi koleksiyon derdi + Visibility: "public", + Parameters: [], + ReturnType: "SettingsDto", + ReturnDtoRef: "SettingsDto", + ReturnsCollection: false, // ama bildirilmis alan tekil diyor -> kazanir + IsAsync: true, + Throws: [], + Description: "Tum ayarlari tek nesnede doner.", + }, + ], + }); + const ctx = ctxFrom([settingsService, settingsDto], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + expect(file.content).toContain("async getAllSettings(): Promise {"); + expect(file.content).not.toContain("Promise"); + }); + + /* ── AUTH GROUNDING: login/register metodlu servis -> auth helper import ─ + * Login/Register fill'i comparePassword/hashPassword/signAccessToken'i KULLANSIN + * diye (duz-metin sifre / sahte token yerine), bu helper'lar servise import edilir + * -> readDeclaredSurface AI'in apiSurface'ine koyar. noUnusedLocals kapali: kullanmazsa + * zararsiz. Auth-metot adi: login/register/signin/signup/authenticate/... */ + it("auth servisi (Login metodu) -> auth helper'larini import eder (grounding)", () => { + const authSvc = node("Service", SVC, { + ServiceName: "AuthService", + Description: "kimlik dogrulama", + IsTransactionScoped: false, + Dependencies: [], + Methods: [ + { MethodName: "Login", Visibility: "public", Parameters: [], ReturnType: "UserResponse", IsAsync: true, Throws: [] }, + ], + }); + const ctx = ctxFrom([authSvc], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + expect(file.content).toContain("comparePassword"); + expect(file.content).toContain("hashPassword"); + expect(file.content).toContain("signAccessToken"); + expect(file.content).toMatch(/from "\.\.\/shared\/auth\/password"/); + expect(file.content).toMatch(/from "\.\.\/shared\/auth\/auth-token"/); + }); + + it("auth WITHOUT servis -> auth helper import ETMEZ", () => { + const ctx = ctxFrom([usersService, usersRepository, createUserDto, userDto, notFoundException], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + expect(file.content).not.toContain("shared/auth"); + }); + + /* ── STATE-MACHINE GROUNDING (L2): status-guncelleyen servis -> assert guard ─ + * Update*Status metodu olan servise, gecis kurali TANIMLI enum'larin + * assertTransition guard'i import edilir -> AI fill'i illegal gecisi + * (pending->delivered atlamasi) reddeder. Gecis kurali NONE enum -> import yok. */ + it("status-guncelleyen servis -> gecisli enum'un assertTransition'ini import eder", () => { + const orderStatus = node("Enum", "e2e2e2e2-2222-4222-8222-e2e2e2e2e2e2", { + Name: "OrderStatus", + Description: "Order status", + BackingType: "string", + Values: [{ Key: "PENDING" }, { Key: "CONFIRMED" }], + Transitions: [{ From: "PENDING", To: ["CONFIRMED"] }], + }); + const orderSvc = node("Service", SVC, { + ServiceName: "OrderService", + Description: "siparis is mantigi", + IsTransactionScoped: false, + Dependencies: [], + Methods: [ + { MethodName: "UpdateStatus", Visibility: "public", Parameters: [{ Name: "id", Type: "string" }, { Name: "status", Type: "string" }], ReturnType: "OrderResponse", IsAsync: true, Throws: [] }, + ], + }); + const ctx = ctxFrom([orderSvc, orderStatus], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + expect(file.content).toContain("assertOrderStatusTransition"); + expect(file.content).toMatch(/from ".*order-status\.enum"/); + }); + + it("gecis kurali NONE enum -> status servisi guard import ETMEZ", () => { + const plainStatus = node("Enum", "e3e3e3e3-3333-4333-8333-e3e3e3e3e3e3", { + Name: "OrderStatus", + Description: "durum", + BackingType: "string", + Values: [{ Key: "PENDING" }, { Key: "CONFIRMED" }], + // Transitions NONE. + }); + const orderSvc = node("Service", SVC, { + ServiceName: "OrderService", + Description: "order", + IsTransactionScoped: false, + Dependencies: [], + Methods: [{ MethodName: "UpdateStatus", Visibility: "public", Parameters: [], ReturnType: "OrderResponse", IsAsync: true, Throws: [] }], + }); + const ctx = ctxFrom([orderSvc, plainStatus], []); + const [file] = emitService(ctx.graph.byId(SVC)!, ctx); + expect(file.content).not.toContain("assertOrderStatusTransition"); + }); + + /* ── EDGE-CASE: kayip ref + bos koleksiyon — ASLA throw etmez ──────────── */ + it("edge-case: kayip DTO/Exception ref + bos Dependencies — throw etmez, ham tip kullanir", () => { + const lonelyService = node("Service", SVC, { + ServiceName: "LonelyService", + Description: "Bagimliliksiz servis", + IsTransactionScoped: false, + Dependencies: [], + Methods: [ + { + MethodName: "ping", + Visibility: "public", + Parameters: [{ Name: "raw", Type: "string", Optional: false, DtoRef: "MissingDto" }], + ReturnType: "boolean", + ReturnDtoRef: "AlsoMissingDto", + IsAsync: false, + Throws: ["GhostException"], + Description: "Kayip ref'ler ham tipe dusmeli.", + }, + ], + }); + // Hicbir ref'i cozen node yok; yalniz servisin kendisi graph'ta. + const ctx = ctxFrom([lonelyService], []); + let file: { content: string; surgicalMarkers: number; path: string } | undefined; + expect(() => { + file = emitService(ctx.graph.byId(SVC)!, ctx)[0]; + }).not.toThrow(); + // Controller yok → Service kendi adindan feature turer ("lonely"); dosya adi + // rol son-ekini ("Service") TEKRARLAMAZ. + expect(file!.path).toBe("lonely/lonely.service.ts"); + // Constructor yok (bos DI), parametre tipi ham "string"e dusmus. + expect(file!.content).not.toContain("constructor("); + // public metot -> async (NestJS idiom + await-sync guvenlik agi); ham ReturnType Promise'le sarilir. + expect(file!.content).toContain("async ping(raw: string): Promise {"); + // Kayip ReturnDtoRef -> ham ReturnType (Promise<> icinde). + expect(file!.content).toContain("): Promise {"); + // Cozulemeyen exception artik SENTETIK dosyadan import edilir (exception-synthesis + // bildirilmis-ama-tanimsiz Throws'u uretir → fill `throw new Ghost...` derlenir, TS2304 yok). + expect(file!.content).toContain('import { GhostException } from "../common/exceptions/ghost.exception";'); + expect(file!.content).toContain("// throws: GhostException"); + expect(file!.surgicalMarkers).toBe(1); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/service.emitter.ts b/apps/server/src/codegen/emitters/nestjs/service.emitter.ts new file mode 100644 index 0000000..e3c1424 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/service.emitter.ts @@ -0,0 +1,406 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import { propsOf, type CodeGraph, type CodeNode, type PropsByKind } from "../../ir"; +import { + camelCase, + filePathFor, + importPathOf, + pascalCase, + relativeImportPath, + resolveTypeRef, + splitWords, +} from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers, notImplemented, surgicalMarker } from "../../surgical"; +import { isArrayType, tokensHaveCollectionSemantics } from "../../cardinality"; +import { synthExceptionFilePath } from "./exception-synthesis"; +import type { NodeKind } from "../../../nodes/schemas"; + +/* ──────────────────────────────────────────────────────────────────────── + * service.emitter.ts — ServiceNode -> /.service.ts. + * + * @Injectable() bir NestJS servisi uretir: + * - DI alanlari: ServiceNode.Dependencies (Kind+Ref) BIRLESIM + * graph.outEdges(id, "CALLS") hedefleri (Repository/Service/Cache/ + * ExternalService). DEDUP edilir, isme gore siralanir, constructor'a + * `private readonly : ` olarak enjekte edilir. + * Cozulebilen ref'ler icin import eklenir; cozulemeyen ref'ler ham + * Ref isminden sinif adi turetir (import atlanir → ASLA throw). + * - Metotlar: Parameters (DtoRef -> DTO tipi+import; yoksa ham Type; + * Optional -> "?"; Default), ReturnType (ReturnDtoRef -> DTO; + * IsAsync -> Promise<>). Govde = surgicalMarker (Description, Throws -> + * Exception, erisilebilir bagimliliklar this.) + notImplemented(). + * + * SAF + DETERMINISTIC: koleksiyonlar sirali, import'lar ImportCollector ile, + * timestamp/random yok, icerik tek "\n" ile biter. + * ──────────────────────────────────────────────────────────────────────── */ + +/** DI ile enjekte edilebilen bagimlilik kind'lari (Dependencies.Kind ⊆ bunlar). */ +const INJECTABLE_KINDS: NodeKind[] = ["Repository", "Service", "Cache", "ExternalService"]; + +/** Bir servisi "auth servisi" sayan kimlik-metodu adlari (onek eslesmesi). Boyle + * bir metot varsa paylasimli auth helper'lari (password/token) import edilir → + * fill grounding'i: Login/Register duz-metin sifre / sahte token yerine bunlari kullanir. */ +const AUTH_METHOD_RE = + /^(login|register|signup|signin|authenticate|refreshtoken|validatetoken|verifytoken|resetpassword|changepassword|forgotpassword)/i; + +/** Tam backend emitter'i OLAN (sinifi `pascalCase(name)` olarak export eden) + * kind'lar. Cache + ExternalService artik tam emitter'a sahip (cache.emitter / + * external-service.emitter) -> gercek sinifi `pascalCase(name)` export ederler + * (Stub eki NONE). DI tipi/import sembolu bununla eslesmek ZORUNDA; ir.ts + * FULL_PROVIDER_KINDS ile birebir tutulmalidir. */ +const FULL_EMITTER_KINDS: ReadonlySet = new Set([ + "Repository", + "Service", + "Cache", + "ExternalService", +]); + +/** Bir node'un uretilen dosyada export ettigi sinif adini dondurur: tam + * emitter'li kind'lar `pascalCase(name)`; stub'lanan kind'lar `pascalCase(name) + * + "Stub"` (stub.emitter.ts ile TEK SOURCE). resolved=null (kayip ref) -> + * ham ref'in pascal'i (kind bilinmez; mevcut davranis korunur). */ +function injectedClassName(resolved: CodeNode | null, rawRef: string): string { + if (!resolved) return pascalCase(rawRef); + const base = pascalCase(resolved.name); + return FULL_EMITTER_KINDS.has(resolved.kindOf()) ? base : `${base}Stub`; +} + +/** Cozulmus bir bagimlilik: DI alani + sinif tipi + (varsa) import yolu. */ +interface ResolvedDep { + /** constructor'da `this.` */ + field: string; + /** enjekte edilen sinif tipi */ + className: string; + /** cozulen node'un dosya yolu (import icin); cozulemezse null. */ + filePath: string | null; +} + +export const emitService: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = propsOf<"Service">(node); + const className = pascalCase(node.name); + const filePath = filePathFor(node, ctx.graph); + const graph = ctx.graph; + + const imports = new ImportCollector(); + imports.add("Injectable", "@nestjs/common"); + + // ── DI bagimliliklari: Dependencies ∪ CALLS hedefleri, DEDUP + isme gore sirali ── + // Cozulemeyen dep'ler (filePath===null) DI'dan DUSURULUR: ciplak tipli constructor + // param'i hem TS2304 (import yok) hem NestJS DI boot patlamasi (saglayici yok) verirdi. + // Bunlar contract-lint Rule 5 ile YUKSEK SESLE bildirilir; in-file de TODO birakilir. + const allDeps = collectDependencies(node, graph); + const deps = allDeps.filter((d) => d.filePath !== null); + const unresolvedDeps = allDeps.filter((d) => d.filePath === null); + for (const dep of deps) { + imports.add(dep.className, importPathOf(relativeImportPath(filePath, dep.filePath!))); + } + + // ── AUTH GROUNDING: kimlik-metodu (login/register/...) tasiyan servise paylasimli + // auth helper'larini import et. Bunlar scaffold tarafindan uretilir; import + // edilince readDeclaredSurface AI'in apiSurface'ine koyar → Login duz-metin sifre + // yerine comparePassword, sahte token yerine signAccessToken kullanir. noUnusedLocals + // kapali → kullanilmazsa zararsiz (dead import degil tsc hatasi NOT). ── + if (props.Methods.some((m) => AUTH_METHOD_RE.test(m.MethodName))) { + imports.add("comparePassword", relativeImportPath(filePath, "shared/auth/password")); + imports.add("hashPassword", relativeImportPath(filePath, "shared/auth/password")); + imports.add("signAccessToken", relativeImportPath(filePath, "shared/auth/auth-token")); + } + + // ── STATE-MACHINE GROUNDING (L2): status-guncelleyen metodu (Update*Status) + // olan servise, gecis kurali TANIMLI enum'larin assertTransition guard'ini + // import et -> AI fill'i illegal durum gecisini (pending->delivered) reddeder. + // Yalniz Transitions tasiyan enum'lar (status enum'lari); Color/Size eklenmez. ── + if (props.Methods.some((m) => /update\w*status/i.test(m.MethodName))) { + for (const en of graph.allOf("Enum")) { + if ((propsOf<"Enum">(en).Transitions ?? []).length === 0) continue; + const enumName = pascalCase(en.name); + imports.add(`assert${enumName}Transition`, importPathOf(relativeImportPath(filePath, filePathFor(en, graph)))); + } + } + + // ── Metotlar ─────────────────────────────────────────────────────────── + const methodBlocks: string[] = []; + // Metotlari MethodName'e gore deterministik sirala. + const methods = [...props.Methods].sort((a, b) => cmp(a.MethodName, b.MethodName)); + for (const m of methods) { + methodBlocks.push(renderMethod(node, className, m, deps, graph, filePath, imports)); + } + + // ── Sinif govdesi ──────────────────────────────────────────────────────── + const lines: string[] = []; + // Anlamli bir aciklama varsa JSDoc bas; tek-harf/bos gurultuyu (ham "s"/"c" + // gibi) atla -> "/** s */" gibi anlamsiz doc uretme. + if (isMeaningfulDoc(props.Description)) lines.push(`/** ${props.Description!.trim()} */`); + lines.push("@Injectable()"); + lines.push(`export class ${className} {`); + + // Cozulemeyen bagimliliklar: DI'dan dusuruldu; in-file TODO ile gorunur kil. + for (const u of unresolvedDeps) { + lines.push( + ` // TODO: dependency "${u.field}" (${u.className}) could not be resolved — omitted from DI (fix the reference).`, + ); + } + + if (deps.length > 0) { + lines.push(" constructor("); + for (const dep of deps) { + lines.push(` private readonly ${dep.field}: ${dep.className},`); + } + lines.push(" ) {}"); + if (methodBlocks.length > 0) lines.push(""); + } + + methodBlocks.forEach((block, i) => { + lines.push(block); + if (i < methodBlocks.length - 1) lines.push(""); + }); + + lines.push("}"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: filePath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/** Dependencies (Kind+Ref) ∪ CALLS edge hedeflerini DEDUP edip isme gore + * siralanmis ResolvedDep listesi dondurur. Cozulemeyen ref'ler ham isimden + * sinif adi turetir (filePath=null → import atlanir). Asla throw etmez. */ +function collectDependencies(node: CodeNode, graph: CodeGraph): ResolvedDep[] { + // refName -> ResolvedDep (DEDUP anahtari: cozulen node.name veya ham ref). + const byKey = new Map(); + + const props = propsOf<"Service">(node); + + // (1) property Dependencies — Kind ipucu var. + for (const dep of props.Dependencies ?? []) { + const resolved = graph.resolveRef(dep.Kind, dep.Ref); + addDep(byKey, resolved, dep.Ref, graph); + } + + // (2) CALLS edge hedefleri — Repository/Service/Cache/ExternalService. + for (const e of graph.outEdges(node.id, "CALLS")) { + const tgt = graph.byId(e.targetNodeId); + if (!tgt) continue; + if (!INJECTABLE_KINDS.includes(tgt.kindOf())) continue; + addDep(byKey, tgt, tgt.name, graph); + } + + return [...byKey.values()].sort((a, b) => cmp(a.field, b.field)); +} + +/** Bir bagimliligi (cozulmus node veya ham ref) DEDUP map'ine ekler. + * COZULEN KAZANIR: ayni isimde bir kayit zaten varsa ama o kayit cozulmemisse + * (filePath===null) ve gelen cozulmusse, kaydi YUKSELT (import kaybini onler). + * Orn. cozulemeyen bir property Dependency, ayni node'a giden cozulebilir bir + * CALLS edge'ini maskelemesin — eskiden ilk-kazanir filePath=null birakiyordu. */ +function addDep( + byKey: Map, + resolved: CodeNode | null, + rawRef: string, + graph: CodeGraph, +): void { + const refName = resolved ? resolved.name : rawRef; + const key = refName; + const existing = byKey.get(key); + if (existing) { + // Mevcut cozulmemis + gelen cozulmus -> yukselt; aksi halde ilk-kazanir. + // Yukseltirken sinif adini da duzelt (stub kind'i `Stub` olabilir). + if (existing.filePath === null && resolved) { + existing.filePath = filePathFor(resolved, graph); + existing.className = injectedClassName(resolved, rawRef); + } + return; + } + byKey.set(key, { + // DI alani node adindan (stub eki tasimaz; "usersCache"). + field: camelCase(refName), + // DI tipi = uretilen sinif adi: tam emitter -> Pascal; stub -> Pascal+"Stub". + className: injectedClassName(resolved, rawRef), + filePath: resolved ? filePathFor(resolved, graph) : null, + }); +} + +/** Tek bir ServiceMethod'u (imza + surgical govde) render eder. */ +function renderMethod( + node: CodeNode, + className: string, + method: ServiceMethod, + deps: ResolvedDep[], + graph: CodeGraph, + fromFile: string, + imports: ImportCollector, +): string { + const indent = " "; + + // ── Parametreler ───────────────────────────────────────────────────────── + const params: string[] = []; + for (const p of method.Parameters ?? []) { + const typeName = resolveTypeName(p.DtoRef, p.Type, graph, fromFile, imports); + const hasDefault = p.Default !== undefined && p.Default !== ""; + // TS: bir parametre HEM "?" HEM "= default" alamaz; default zaten parametreyi + // implicit opsiyonel yapar. Default varsa "?" dusurulur. + const optional = p.Optional && !hasDefault ? "?" : ""; + const def = hasDefault ? ` = ${p.Default}` : ""; + params.push(`${p.Name}${optional}: ${typeName}${def}`); + } + const paramList = params.join(", "); + + // ── Donus tipi ─────────────────────────────────────────────────────────── + // TEK-SOURCE KARDINALITE: ReturnsCollection bildirildiyse (true) donus tipini + // DTO[]'e zorla — graf tekil ReturnType verse bile (or. ListProducts: ReturnType + // 'ProductDto' ama operasyon koleksiyon). Boylece service imzasi controller'in + // koleksiyon karariyla HIZALI kalir; aksi halde tekil imza + dizi donduren surgical + // govde derleme hatasi verirdi (gercek bug). Zaten dizi/Array<> tasiyan tip IKI + // KEZ sarilmaz. + let innerReturn = resolveTypeName(method.ReturnDtoRef, method.ReturnType, graph, fromFile, imports); + // Bildirilmis ReturnsCollection (true/false) KAZANIR; yoksa metot-adi liste- + // semantigi fallback'i (list/all/search/findAll/findMany). Zaten dizi olan tip + // (or. ReturnType 'XDto[]') IKI KEZ sarilmaz. + const returnsCollection = + method.ReturnsCollection ?? tokensHaveCollectionSemantics(splitWords(method.MethodName)); + if (returnsCollection && !isArrayType(innerReturn)) { + innerReturn = `${innerReturn}[]`; + } + // ── ASYNC: PUBLIC service metotlari DAIMA async (NestJS idiom + guvenlik agi). + // Public bir metot neredeyse her zaman I/O yapar (repo/servis cagrisi → await); + // graf IsAsync:false dese bile surgical fill `await` kullaninca sync imza TS1308 + // ile kirilirdi (gercek bug: AuthService.ValidateToken). Public → async (Promise + // sarmali); private metotlar graf IsAsync'ini KORUR (saf yardimci olabilir). + const isAsync = method.IsAsync || method.Visibility === "public"; + const returnType = isAsync ? `Promise<${innerReturn}>` : innerReturn; + + // ── Erisilebilir bagimliliklar (this.) ────────────────────────────── + const depFields = deps.map((d) => `this.${d.field}`); + + // ── Firlatilabilir Exception'lar — HER ZAMAN import edilir. ──────────────── + // Cozulen Exception node'u → o dosyadan. Cozulmeyen (bildirilmis-ama-tanimsiz + // Throws) → exception-synthesis SENTETIK sinifi uretir; import'u da oradan yap + // (TEK SOURCE synthException*). Aksi halde marker fill'i `throw new X` uretmeye + // zorlar ama X import'suz/tanimsiz kalir → TS2304 (gercek bug: PlaceOrder'in + // CartEmptyException'i). Sentetik dosya assemble'da emitSyntheticException ile basilir. + const throwsNames: string[] = []; + for (const exName of method.Throws ?? []) { + const exNode = graph.resolveRef("Exception", exName); + const exClass = pascalCase(exNode ? exNode.name : exName); + throwsNames.push(exClass); + const toPath = exNode ? filePathFor(exNode, graph) : synthExceptionFilePath(exName); + imports.add(exClass, importPathOf(relativeImportPath(fromFile, toPath))); + } + + const marker = surgicalMarker({ + nodeId: node.id, + member: method.MethodName, + description: method.Description, + throws: throwsNames.length > 0 ? throwsNames : undefined, + deps: depFields.length > 0 ? depFields : undefined, + }); + + const asyncKw = isAsync ? "async " : ""; + const visibility = method.Visibility && method.Visibility !== "public" ? `${method.Visibility} ` : ""; + + const lines: string[] = []; + lines.push(`${indent}${visibility}${asyncKw}${method.MethodName}(${paramList}): ${returnType} {`); + for (const ml of marker.split("\n")) lines.push(`${indent}${indent}${ml}`); + lines.push(`${indent}${indent}${notImplemented(className, method.MethodName)}`); + lines.push(`${indent}}`); + return lines.join("\n"); +} + +/** Bir parametre/donus tipini cozer: DtoRef varsa DTO sinif adi (+import), + * yoksa ham Type NORMALIZE edilir (resolveTypeRef: UUID->string, User->import+ + * sinif). Cozulemeyen serbest ad oldugu gibi gecer (controller.emitter ile ayni + * tolerans) ama skaler es anlamlilar ve entity/DTO/Enum adlari cozulur -> TS2304 + * onlenir. + * + * DIZI/SARMALAYICI KORUMA: graf zaten dizi donusleri icin ReturnType="XDto[]" + * (or. "CartItemDto[]") verir ama DtoRef de doludur (DTO sinifini isaret eder). + * Eskiden DtoRef dolu oldugunda ham Type TAMAMEN atilir, ciplak "CartItemDto" + * donerdi -> service tekil, controller (resolveTypeRef'ten gectigi icin) dizi -> + * UYUMSUZ imza. Duzeltme: DtoRef SINIF ADINI cozse bile ham Type'taki + * dizi/sarmalayici son-ekini ([], Array<>, <>, | null/undefined ...) KORU. + * Yontem: ham Type icindeki ciplak tanimlayiciyi (DTO adi) cozulmus sinif adina + * yer-degistir, cevreleyen sarmalayiciyi (resolveTypeRef'in korudugu <>[]| vb.) + * oldugu gibi birak. Ham Type sarmalayici icermiyorsa (tekil, or. "unknown" + + * DtoRef) MEVCUT davranis korunur: ciplak DTO sinifi doner. */ +function resolveTypeName( + dtoRef: string | undefined, + rawType: string, + graph: CodeGraph, + fromFile: string, + imports: ImportCollector, +): string { + if (dtoRef && dtoRef !== "") { + const dtoNode = graph.resolveRef("DTO", dtoRef); + if (dtoNode) { + const dtoClass = pascalCase(dtoNode.name); + // DEGER import'u (type-only NOT): surgical AI govdede DTO'yu runtime + // deger olarak kullanir (plainToInstance(CreateUserDto, ...), validate(...)); + // `import type` olsaydi bu kullanim derlenmezdi. Controller.emitter @Body + // DTO'sunu da DEGER import eder (class-validator runtime) -> tutarli. + imports.add(dtoClass, importPathOf(relativeImportPath(fromFile, filePathFor(dtoNode, graph)))); + // Ham Type bir dizi/sarmalayici tasiyorsa onu KORU (controller ile hizali): + // "CartItemDto[]" -> "CartItemDto[]", "Promise" -> "Promise". + // Tekil ham Type (sarmalayicisiz) -> ciplak DTO sinifi (mevcut davranis). + return applyTypeWrapper(rawType, dtoClass); + } + // Cozulemeyen DtoRef -> ham Type'i normalize et; yoksa ref ismini koru. + return rawType && rawType !== "" ? resolveTypeRef(rawType, graph, fromFile, imports) : pascalCase(dtoRef); + } + return resolveTypeRef(rawType, graph, fromFile, imports); +} + +/** Ham bir tip stringinin SARMALAYICISINI cozulmus sinif adina uygular. + * + * Ham Type icindeki TEK ciplak tanimlayici parcasini (DTO adi) `resolvedClass` + * ile yer-degistirir; cevredeki sarmalayici sembolleri ([], <>, |, Array, Promise, + * bosluk, null, undefined ...) OLDUGU GIBI korur. Boylece: + * "CartItemDto[]" + UserDto -> "UserDto[]" (DtoRef sinifi, dizi korunur) + * "CartItemDto" + UserDto -> "UserDto" (tekil; mevcut davranis) + * "unknown" / "" + UserDto -> "UserDto" (sarmalayici yok -> ciplak) + * "Promise" + UserDto -> "Promise" (sarmalayici korunur) + * + * Ham Type'ta tam olarak BIR tip-tanimlayicisi (TS anahtar kelimesi olmayan) + * varsa onu resolvedClass ile degistirir. Aksi halde (0 ya da >1 tanimlayici, + * or. union "A | B") sarmalayiciyi guvenle esleyemeyiz -> ciplak resolvedClass'a + * duseriz (mevcut tekil davranis; determinizm + guvenli taraf). */ +function applyTypeWrapper(rawType: string, resolvedClass: string): string { + const t = (rawType ?? "").trim(); + if (t.length === 0) return resolvedClass; + // Tip-tanimlayicisi parcalari (TS sarmalayici anahtar kelimeleri HARIC). + const ids = (t.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).filter((tok) => !TYPE_WRAPPER_KEYWORDS.has(tok)); + // Tam olarak bir tip-tanimlayicisi yoksa sarmalayiciyi guvenle esleyemeyiz. + if (ids.length !== 1) return resolvedClass; + // O tek tanimlayiciyi resolvedClass ile degistir; sarmalayiciyi koru. + return t.replace(/[A-Za-z_][A-Za-z0-9_]*/g, (tok) => + TYPE_WRAPPER_KEYWORDS.has(tok) ? tok : resolvedClass, + ); +} + +/** Sarmalayici/yapisal tip anahtar kelimeleri: bunlar bir DTO adi NOTDIR, ham + * Type'ta gorunseler bile yer-degistirme disi tutulur (sarmalayici parcasi). */ +const TYPE_WRAPPER_KEYWORDS: ReadonlySet = new Set([ + "Promise", "Array", "Readonly", "Partial", "null", "undefined", "void", +]); + +/** Deterministik string karsilastirmasi. */ +function cmp(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + + +/** Bir Description'in anlamli bir JSDoc'a degip degmedigi: trim sonrasi >=3 char. + * Tek-harf/bos aciklamalar ("s", "c", " ") JSDoc gurultusu; atlanir. */ +function isMeaningfulDoc(desc: string | undefined): boolean { + return typeof desc === "string" && desc.trim().length >= 3; +} + +/* ── Yerel tip: ServiceMethod (service.schema.ts ile ayni shape) ──────────── */ +type ServiceProps = PropsByKind["Service"]; +type ServiceMethod = ServiceProps["Methods"][number]; diff --git a/apps/server/src/codegen/emitters/nestjs/sql-type-map.spec.ts b/apps/server/src/codegen/emitters/nestjs/sql-type-map.spec.ts new file mode 100644 index 0000000..2b74756 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/sql-type-map.spec.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from "vitest"; +import { columnOrmType, columnTsType, sqlTypeToTs } from "./sql-type-map"; +import { buildCodeGraph } from "../../ir"; +import type { StoredNode } from "../../../nodes/nodes.repository"; + +/* ──────────────────────────────────────────────────────────────────────── + * sql-type-map.spec.ts — SQL DataType -> (TS type, TypeORM @Column type). + * SINGLE SOURCE for entity/model/dto emitters; valid TS including ENUM/JSON. + * ──────────────────────────────────────────────────────────────────────── */ + +function enumNode(): StoredNode { + return { + id: "44444444-4444-4444-8444-444444444444", + type: "Enum", + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties: { Name: "OrderStatus", Description: "x", BackingType: "string", Values: [{ Key: "A" }] }, + }; +} + +describe("sqlTypeToTs", () => { + it("string family -> string (VARCHAR/TEXT/CHAR/UUID, case-insensitive)", () => { + for (const t of ["VARCHAR", "text", "Char", "uuid", "BPCHAR", "citext"]) { + expect(sqlTypeToTs(t)).toBe("string"); + } + }); + + it("numeric family -> number (INT/INTEGER/BIGINT/SMALLINT/DECIMAL/NUMERIC/FLOAT)", () => { + for (const t of ["INT", "integer", "BIGINT", "SMALLINT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "REAL"]) { + expect(sqlTypeToTs(t)).toBe("number"); + } + }); + + it("BOOLEAN/BOOL -> boolean", () => { + expect(sqlTypeToTs("BOOLEAN")).toBe("boolean"); + expect(sqlTypeToTs("bool")).toBe("boolean"); + }); + + it("time family -> Date (TIMESTAMP/DATETIME/DATE/TIME)", () => { + for (const t of ["TIMESTAMP", "DATETIME", "DATE", "TIME", "timestamptz"]) { + expect(sqlTypeToTs(t)).toBe("Date"); + } + }); + + it("JSON/JSONB/object/map/record -> Record", () => { + expect(sqlTypeToTs("JSON")).toBe("Record"); + expect(sqlTypeToTs("jsonb")).toBe("Record"); + expect(sqlTypeToTs("object")).toBe("Record"); + expect(sqlTypeToTs("map")).toBe("Record"); + }); + + it("ENUM -> string (use columnTsType for generated type)", () => { + expect(sqlTypeToTs("ENUM")).toBe("string"); + }); + + it("empty/undefined -> string", () => { + expect(sqlTypeToTs("")).toBe("string"); + expect(sqlTypeToTs(undefined)).toBe("string"); + }); + + it("unknown type: unknownAsString=true -> string; false -> raw passthrough", () => { + expect(sqlTypeToTs("Buffer")).toBe("string"); + expect(sqlTypeToTs("Buffer", false)).toBe("Buffer"); + // free type (DTO/Model) passthrough: custom class name preserved. + expect(sqlTypeToTs("GeoPoint", false)).toBe("GeoPoint"); + }); + + it("binary/file family (binary/blob/bytes) -> Buffer — DTO path too (raw `binary` was TS2304)", () => { + for (const t of ["binary", "BINARY", "varbinary", "blob", "BLOB", "bytea", "bytes"]) { + expect(sqlTypeToTs(t)).toBe("Buffer"); + expect(sqlTypeToTs(t, false)).toBe("Buffer"); // DTO/Model path must not return raw `binary` + } + }); + + it(".NET/boxed type name -> primitive (String->string, Number->number) — DTO/Model path too", () => { + // unknownAsString=false (DTO/Model) must not return raw "String"; otherwise + // `Type 'String' is not assignable to type 'string'` (TS2322). + expect(sqlTypeToTs("String", false)).toBe("string"); + expect(sqlTypeToTs("string", false)).toBe("string"); + expect(sqlTypeToTs("Number", false)).toBe("number"); + expect(sqlTypeToTs("STRING")).toBe("string"); + }); +}); + +describe("columnTsType (ENUM resolution)", () => { + it("ENUM + resolved EnumRef -> generated enum class name", () => { + const graph = buildCodeGraph([enumNode()], []); + expect(columnTsType("ENUM", "OrderStatus", graph)).toBe("OrderStatus"); + }); + + it("ENUM + unresolved EnumRef -> string (safe)", () => { + const graph = buildCodeGraph([], []); + expect(columnTsType("ENUM", "Missing", graph)).toBe("string"); + }); + + it("ENUM but no EnumRef -> string", () => { + const graph = buildCodeGraph([enumNode()], []); + expect(columnTsType("ENUM", undefined, graph)).toBe("string"); + }); + + it("enumImporter callback invoked with resolved node", () => { + const graph = buildCodeGraph([enumNode()], []); + let seen = ""; + const out = columnTsType("ENUM", "OrderStatus", graph, (n) => { + seen = n.name; + return "Aliased"; + }); + expect(seen).toBe("OrderStatus"); + expect(out).toBe("Aliased"); + }); + + it("non-ENUM type -> same as sqlTypeToTs (callback not invoked)", () => { + const graph = buildCodeGraph([], []); + expect(columnTsType("JSON", undefined, graph)).toBe("Record"); + expect(columnTsType("UUID", undefined, graph)).toBe("string"); + }); +}); + +describe("columnOrmType (TypeORM @Column type)", () => { + it("SQL types map to correct physical TypeORM type", () => { + expect(columnOrmType("VARCHAR")).toBe("varchar"); + expect(columnOrmType("TEXT")).toBe("text"); + expect(columnOrmType("UUID")).toBe("uuid"); + expect(columnOrmType("INT")).toBe("int"); + expect(columnOrmType("BIGINT")).toBe("bigint"); + expect(columnOrmType("BOOLEAN")).toBe("boolean"); + expect(columnOrmType("DATETIME")).toBe("timestamp"); + expect(columnOrmType("DATE")).toBe("date"); + expect(columnOrmType("FLOAT")).toBe("double precision"); + expect(columnOrmType("DECIMAL")).toBe("decimal"); + expect(columnOrmType("JSON")).toBe("jsonb"); + expect(columnOrmType("JSONB")).toBe("jsonb"); + expect(columnOrmType("ENUM")).toBe("enum"); + }); + + it("object/map/record (schemaless JSON blob) -> jsonb (TypeORM has no 'object' type; old TS2769)", () => { + expect(columnOrmType("object")).toBe("jsonb"); + expect(columnOrmType("OBJECT")).toBe("jsonb"); + expect(columnOrmType("map")).toBe("jsonb"); + expect(columnOrmType("record")).toBe("jsonb"); + }); + + it("Model free-type synonyms map to same physical type", () => { + expect(columnOrmType("string")).toBe("varchar"); + expect(columnOrmType("number")).toBe("int"); + expect(columnOrmType("bool")).toBe("boolean"); + expect(columnOrmType("long")).toBe("bigint"); + }); + + it("binary/file family (binary/blob/bytes) -> bytea (postgres; raw 'binary' is MySQL-specific)", () => { + for (const t of ["binary", "BINARY", "varbinary", "blob", "bytea", "bytes"]) { + expect(columnOrmType(t)).toBe("bytea"); + } + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/sql-type-map.ts b/apps/server/src/codegen/emitters/nestjs/sql-type-map.ts new file mode 100644 index 0000000..3db1331 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/sql-type-map.ts @@ -0,0 +1,206 @@ +import type { CodeGraph, CodeNode } from "../../ir"; +import { pascalCase } from "../../naming"; + +/* ──────────────────────────────────────────────────────────────────────── + * sql-type-map.ts — SQL DataType -> (TypeScript type, TypeORM @Column type). + * + * SINGLE SOURCE. entity-synthesis / model.emitter / dto.emitter (and indirect table + * side) share this module; otherwise each emitter keeps its own incomplete mapping and + * types like ENUM/JSON would produce INVALID TS (old bug: `status: ENUM`, + * `metadata: JSON` — both break compilation / wrong type). + * + * MAPPING (case-insensitive; synonyms normalized): + * VARCHAR / TEXT / CHAR / UUID -> string + * INT / INTEGER / BIGINT / SMALLINT -> number + * DECIMAL / NUMERIC / FLOAT / DOUBLE / REAL -> number + * BOOLEAN / BOOL -> boolean + * TIMESTAMP / DATETIME / DATE / TIME -> Date + * JSON / JSONB -> Record + * ENUM -> generated enum type when + * EnumRef resolves, else string + * + * PURE + DETERMINISTIC: string only + (for ENUM) ref resolution on graph; + * no timestamp/random. Unknown type -> string (safe; no invalid TS). + * ──────────────────────────────────────────────────────────────────────── */ + +/** Reduce a SQL DataType token to normalized uppercase form. */ +function norm(raw: string | undefined): string { + return (raw ?? "").trim().toUpperCase(); +} + +/** SQL/schema DataType -> TypeScript SCALAR type (except ENUM — use + * columnTsType for ENUM because resolving generated enum name requires graph). + * + * unknownAsString: + * - true (default): unknown type -> "string" (entity/Table safe path; + * SQL DataType enum is closed, no invalid TS). + * - false: unknown type returned AS-IS (DTO/Model free-type passthrough — + * e.g. custom class/embedded type name; preserves old behavior). + */ +export function sqlTypeToTs( + dataType: string | undefined, + unknownAsString = true, +): string { + switch (norm(dataType)) { + case "": + return "string"; + case "VARCHAR": + case "TEXT": + case "CHAR": + case "BPCHAR": + case "CITEXT": + case "GUID": + case "UUID": + case "STRING": + // .NET/C# boxed type name (DataType="String"/"Guid"): with unknownAsString=false + // (DTO/Model path) a raw "String" would yield `Type 'String' is not assignable to + // type 'string'` (TS2322). Lower the boxed wrapper to the primitive. + return "string"; + case "INT": + case "INTEGER": + case "BIGINT": + case "SMALLINT": + case "TINYINT": + case "LONG": + case "DECIMAL": + case "NUMERIC": + case "FLOAT": + case "DOUBLE": + case "REAL": + case "NUMBER": + // .NET-style boxed number name ("Number"/"Int32") → primitive number. + return "number"; + case "BOOLEAN": + case "BOOL": + return "boolean"; + case "TIMESTAMP": + case "TIMESTAMPTZ": + case "DATETIME": + case "DATE": + case "TIME": + return "Date"; + // JSON blob synonyms: object/map/record/dict (LLM may label a schemaless body field + // "object"; bare `object` is weak but Record is consistent). + case "JSON": + case "JSONB": + case "OBJECT": + case "MAP": + case "RECORD": + case "DICT": + return "Record"; + case "ENUM": + return "string"; + // Binary/file data (file upload, blob). Raw `binary` is INVALID TS (TS2304: Cannot + // find name 'binary') — DTO path had unknownAsString=false so it passed through raw. + // Valid TS: Buffer (Node binary type). + case "BINARY": + case "VARBINARY": + case "BLOB": + case "LONGBLOB": + case "MEDIUMBLOB": + case "TINYBLOB": + case "BYTEA": + case "BYTES": + case "BYTE": + return "Buffer"; + // Parameterless collection name (DataType="Array"/"List", no element type) → bare + // `Array` is INVALID TS (TS2314). Safe degradation: `unknown` (with IsArray → unknown[]). + case "ARRAY": + case "LIST": + return "unknown"; + default: + // Unknown type: safe "string" for entity; raw passthrough for DTO/Model. + return unknownAsString ? "string" : (dataType ?? "string"); + } +} + +/** Column TS type — for ENUM returns generated enum class name (when EnumRef resolves) + * and allows import via `imports` callback; otherwise sqlTypeToTs. enumImporter(node) + * receives resolved Enum node and must return class name (after adding import) — + * return null to fall back to "string". */ +export function columnTsType( + dataType: string | undefined, + enumRef: string | undefined, + graph: CodeGraph | undefined, + enumImporter?: (enumNode: CodeNode) => string, +): string { + if (norm(dataType) === "ENUM" && enumRef && graph) { + const enumNode = graph.resolveRef("Enum", enumRef); + if (enumNode) { + return enumImporter ? enumImporter(enumNode) : pascalCase(enumNode.name); + } + } + return sqlTypeToTs(dataType); +} + +/** TypeORM `@Column` `type` option for a column (deterministic). ENUM/JSON included — + * physical type suitable for TypeORM. ENUM -> "enum" (caller also adds enum: TheEnum), + * JSON/JSONB -> "jsonb". */ +export function columnOrmType(dataType: string | undefined): string { + switch (norm(dataType)) { + // SQL + Model free-type synonyms map to same physical TypeORM type. + case "VARCHAR": + case "CHAR": + case "BPCHAR": + case "CITEXT": + case "STRING": + return "varchar"; + case "TEXT": + return "text"; + case "GUID": + case "UUID": + return "uuid"; + case "INT": + case "INTEGER": + case "SMALLINT": + case "TINYINT": + case "NUMBER": + return "int"; + case "BIGINT": + case "LONG": + return "bigint"; + case "BOOLEAN": + case "BOOL": + return "boolean"; + case "TIMESTAMP": + case "TIMESTAMPTZ": + case "DATETIME": + return "timestamp"; + case "DATE": + return "date"; + case "TIME": + return "time"; + case "FLOAT": + case "DOUBLE": + case "REAL": + return "double precision"; + case "DECIMAL": + case "NUMERIC": + return "decimal"; + // JSON blob synonyms → jsonb. TypeORM has no "object" column type; + // default would emit `type: "object"` (TS2769: no overload — real bug). + case "JSON": + case "JSONB": + case "OBJECT": + case "MAP": + case "RECORD": + case "DICT": + return "jsonb"; + // Binary/file data → postgres bytea (TypeORM raw `binary` is MySQL-specific; + // codegen targets postgres → bytea is consistent). + case "BINARY": + case "VARBINARY": + case "BLOB": + case "LONGBLOB": + case "MEDIUMBLOB": + case "TINYBLOB": + case "BYTEA": + case "BYTES": + case "BYTE": + return "bytea"; + case "ENUM": + return "enum"; + default: + return norm(dataType).length > 0 ? norm(dataType).toLowerCase() : "varchar"; + } +} diff --git a/apps/server/src/codegen/emitters/nestjs/stub.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/stub.emitter.spec.ts new file mode 100644 index 0000000..baff160 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/stub.emitter.spec.ts @@ -0,0 +1,195 @@ +import { describe, it, expect } from "vitest"; +import { emitStub } from "./stub.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; +import type { NodeKind } from "../../../nodes/schemas"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +function node(type: NodeKind, properties: Record, id: string): StoredNode { + return { + id, + type, + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function edge(kind: StoredEdge["kind"], sourceNodeId: string, targetNodeId: string, id: string): StoredEdge { + return { + id, + projectId: "00000000-0000-4000-8000-000000000000", + sourceNodeId, + targetNodeId, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: false }, + }; +} + +function ctxFor(nodes: StoredNode[], edges: StoredEdge[]): { ctx: EmitterContext } { + const graph = buildCodeGraph(nodes, edges); + return { ctx: { graph, target: "nestjs" } }; +} + +/* Fixed UUIDs — determinism + readability. */ +const ID_CACHE = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const ID_SERVICE = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const ID_QUEUE = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; +const ID_VIEW = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; +const ID_DANGLING = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; +const ID_APP = "ffffffff-ffff-4fff-8fff-ffffffffffff"; +const ID_UI = "11111111-aaaa-4aaa-8aaa-111111111111"; + +const CACHE_PROPS = { + CacheName: "UserSessionCache", + Description: "Redis cache for session data", + KeyPattern: "session:{userId}", + TTL_Seconds: 3600, + Engine: "Redis", +}; + +/* Cache/View now have full emitters (filePathFor routes them to .cache.ts / migration) + * -> emitStub uses remaining out-of-scope types for stub path (stubs/...stub.ts): + * FrontendApp / UIComponent (EXCLUDED_KINDS, default stub branch). */ +const APP_PROPS = { + AppName: "AdminWebApp", + Description: "Admin single-page application", + Framework: "React", +}; + +describe("emitStub (12 out-of-scope types — single stub emitter)", () => { + it("FrontendApp (remaining out-of-scope) + edge summary — snapshot", () => { + const app = node("FrontendApp", APP_PROPS, ID_APP); + const svc = node("Service", { ServiceName: "UserService" }, ID_SERVICE); + const queue = node("MessageQueue", { QueueName: "EventsQueue" }, ID_QUEUE); + // Service -CALLS-> FrontendApp (incoming), FrontendApp -REQUESTS-> MessageQueue (outgoing) + const edges = [ + edge("CALLS", ID_SERVICE, ID_APP, "10000000-0000-4000-8000-000000000001"), + edge("REQUESTS", ID_APP, ID_QUEUE, "10000000-0000-4000-8000-000000000002"), + ]; + const { ctx } = ctxFor([app, svc, queue], edges); + const [file] = emitStub(ctx.graph.byId(ID_APP)!, ctx); + // FrontendApp is NOT an injected provider -> no @Injectable(). File lives under + // /stubs/ not feature root (don't mix with real code). + expect(file).toMatchInlineSnapshot(` + { + "content": "/** + * FrontendApp — out-of-scope node (the v1 backend chain does not generate it). + * + * This file is intentionally a STUB: generated so the node is not dropped from the graph. + * Surgical AI fills in the target behavior at the marked point below. + */ + // @solarch:surgical id=ffffffff-ffff-4fff-8fff-ffffffffffff#stub + // out-of-scope: FrontendApp "AdminWebApp" is not deterministically generated in v1 + // Admin single-page application + // + // edges: + // REQUESTS -> MessageQueue EventsQueue + // Service UserService -> CALLS (incoming) + export class AdminWebAppStub {} + ", + "language": "typescript", + "path": "user/stubs/admin-web-app.frontend-app.stub.ts", + "surgicalMarkers": 1, + } + `); + }); + + it("leaves exactly 1 surgical marker", () => { + const cache = node("Cache", CACHE_PROPS, ID_CACHE); + const { ctx } = ctxFor([cache], []); + const [file] = emitStub(ctx.graph.byId(ID_CACHE)!, ctx); + expect(file.surgicalMarkers).toBe(1); + expect(file.content).toContain(`// @solarch:surgical id=${ID_CACHE}#stub`); + }); + + it("emits exported placeholder class (not silently dropped)", () => { + const cache = node("Cache", CACHE_PROPS, ID_CACHE); + const { ctx } = ctxFor([cache], []); + const [file] = emitStub(ctx.graph.byId(ID_CACHE)!, ctx); + expect(file.content).toContain("export class UserSessionCacheStub {}"); + expect(file.language).toBe("typescript"); + }); + + it("file path via filePathFor as /stubs/..stub.ts", () => { + // FrontendApp is a remaining out-of-scope type -> filePathFor default stub branch. + const app = node("FrontendApp", APP_PROPS, ID_APP); + const { ctx } = ctxFor([app], []); + const [file] = emitStub(ctx.graph.byId(ID_APP)!, ctx); + // Stubs not scattered at feature root; separate stubs/ subfolder (don't mix with real code). + expect(file.path).toBe("common/stubs/admin-web-app.frontend-app.stub.ts"); + expect(file.path.includes("/stubs/")).toBe(true); + expect(file.path.endsWith(".stub.ts")).toBe(true); + }); + + it("outgoing + incoming edges summarized with correct direction markers", () => { + const cache = node("Cache", CACHE_PROPS, ID_CACHE); + const svc = node("Service", { ServiceName: "UserService" }, ID_SERVICE); + const queue = node("MessageQueue", { QueueName: "EventsQueue" }, ID_QUEUE); + const edges = [ + edge("CACHES_IN", ID_SERVICE, ID_CACHE, "10000000-0000-4000-8000-000000000001"), + edge("PUBLISHES", ID_CACHE, ID_QUEUE, "10000000-0000-4000-8000-000000000002"), + ]; + const { ctx } = ctxFor([cache, svc, queue], edges); + const [file] = emitStub(ctx.graph.byId(ID_CACHE)!, ctx); + expect(file.content).toContain("PUBLISHES -> MessageQueue EventsQueue"); + expect(file.content).toContain("Service UserService -> CACHES_IN (incoming)"); + }); + + it("EDGE-CASE: disconnected node -> 'edges: (none)'", () => { + // UIComponent is a remaining out-of-scope type (View now emits real SQL migration). + const ui = node("UIComponent", { ComponentName: "UserCard", Description: "User card" }, ID_UI); + const { ctx } = ctxFor([ui], []); + const [file] = emitStub(ctx.graph.byId(ID_UI)!, ctx); + expect(file.content).toContain("// edges: (none)"); + expect(file.content).toContain("export class UserCardStub {}"); + // UIComponent is non-injected stub -> no @Injectable(). + expect(file.content).not.toContain("@Injectable()"); + expect(file.path).toBe("common/stubs/user-card.ui-component.stub.ts"); + }); + + it("EDGE-CASE: edge with missing ref endpoint -> '(?)' (does not throw)", () => { + const cache = node("Cache", CACHE_PROPS, ID_CACHE); + // target node not in fixture -> resolve null -> expect "(?)". + const edges = [edge("PUBLISHES", ID_CACHE, ID_DANGLING, "10000000-0000-4000-8000-000000000003")]; + const { ctx } = ctxFor([cache], edges); + expect(() => emitStub(ctx.graph.byId(ID_CACHE)!, ctx)).not.toThrow(); + const [file] = emitStub(ctx.graph.byId(ID_CACHE)!, ctx); + expect(file.content).toContain("PUBLISHES -> (?)"); + }); + + it("content ends with single newline", () => { + const cache = node("Cache", CACHE_PROPS, ID_CACHE); + const { ctx } = ctxFor([cache], []); + const [file] = emitStub(ctx.graph.byId(ID_CACHE)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: same node twice -> byte-identical", () => { + const cache = node("Cache", CACHE_PROPS, ID_CACHE); + const svc = node("Service", { ServiceName: "UserService" }, ID_SERVICE); + const edges = [edge("CACHES_IN", ID_SERVICE, ID_CACHE, "10000000-0000-4000-8000-000000000001")]; + const { ctx } = ctxFor([cache, svc], edges); + const a = emitStub(ctx.graph.byId(ID_CACHE)!, ctx)[0].content; + const b = emitStub(ctx.graph.byId(ID_CACHE)!, ctx)[0].content; + expect(a).toBe(b); + }); + + it("node without Description -> description line skipped (no throw)", () => { + const worker = node("Worker", { WorkerName: "CleanupWorker" }, ID_VIEW); + const { ctx } = ctxFor([worker], []); + const [file] = emitStub(ctx.graph.byId(ID_VIEW)!, ctx); + expect(file.content).toContain('out-of-scope: Worker "CleanupWorker"'); + expect(file.content).toContain("export class CleanupWorkerStub {}"); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/stub.emitter.ts b/apps/server/src/codegen/emitters/nestjs/stub.emitter.ts new file mode 100644 index 0000000..47cf0a3 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/stub.emitter.ts @@ -0,0 +1,144 @@ +import type { EmitterContext, GeneratedFile, NodeEmitter } from "../../types"; +import type { CodeGraph, CodeNode } from "../../ir"; +import { filePathFor, pascalCase } from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers } from "../../surgical"; +import type { NodeKind } from "../../../nodes/schemas"; + +/* ──────────────────────────────────────────────────────────────────────── + * stub.emitter.ts — MINIMAL stub emitter for out-of-scope nodes. + * + * @deprecated NOT TRIGGERED IN REAL PRODUCTION. EMITTER_REGISTRY (index.ts) + * does not include this emitter at all; no registry entry routes a node to stub. + * This is a legacy fallback only invoked via direct/test calls (stub.emitter.spec.ts). + * + * WHY THIS IS NOW A DEAD PATH: types once outside the v1 backend chain + * (Cache, MessageQueue, Worker, APIGateway, EventHandler, Orchestrator, + * ExternalService, Middleware, View) used to fall through to this emitter. Now ALL + * have full emitters (registry supported: true) -> they never hit stub. + * Only three out-of-scope types remain and they also do NOT produce stubs: + * - FrontendApp / UIComponent -> EXCLUDED_KINDS (not in registry); + * codegen.service isExcluded counts them in skippedKinds without generating FILES. + * - EnvironmentVariable -> not in registry; represents scaffold config, + * not a code module; again no file is generated. + * Net: zero nodes flow to this emitter in live codegen today. + * + * Behavior (when called directly) is still correct and tested; leaves a minimal + * skeleton file so the node is NOT SILENTLY DROPPED; records its place in the graph + * (in/out edge summary) and leaves a Surgical AI marker point. + * + * Contract: + * - no default export; named `export const emitStub: NodeEmitter`. + * - PURE function: (node, ctx) -> GeneratedFile[]. No I/O, no throw. + * - Path always via filePathFor(node, ctx.graph) (hardcode FORBIDDEN). + * - Content DETERMINISTIC: edge summary sorted by name, no timestamp/random. + * - imports via ImportCollector; content ends with single "\n". + * + * NOTE: stubbed types are NOT in PropsByKind — propsOf<...> CANNOT be used. + * Only safe fields: node.name (resolved by ir) + generic Description. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Stub kinds injectable into Service via DI. + * @deprecated EFFECTIVELY EMPTY: Cache + ExternalService now have full emitters + * (registry supported: true) -> never fall through to emitStub. This set only + * preserves @Injectable() behavior on direct test calls (legacy); no live + * production node matches this set. */ +const INJECTABLE_STUB_KINDS: ReadonlySet = new Set(["Cache", "ExternalService"]); + +/** Class name exported by a stub node (SINGLE SOURCE). "ImageResultCache" + * -> "ImageResultCacheStub". module.emitter uses this for provider imports. */ +export function stubClassName(node: CodeNode): string { + return `${pascalCase(node.name) || pascalCase(node.kindOf())}Stub`; +} + +/** File path for a stub node (SINGLE SOURCE = filePathFor default branch). */ +export function stubFilePath(node: CodeNode, graph: CodeGraph): string { + return filePathFor(node, graph); +} + +export const emitStub: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const kind = node.kindOf(); + const className = stubClassName(node); + const description = readDescription(node); + // Injectable stubs (Cache/ExternalService) must be @Injectable() — + // added to module providers, resolved by NestJS DI at boot. + const isInjectable = INJECTABLE_STUB_KINDS.has(kind); + + // Stub placeholder needs no imports; collector set up to show the pattern. + const imports = new ImportCollector(); + if (isInjectable) imports.add("Injectable", "@nestjs/common"); + + const lines: string[] = []; + + // Top banner — explains why this node did not generate code and what it is. + lines.push("/**"); + lines.push(` * ${kind} — out-of-scope node (the v1 backend chain does not generate it).`); + lines.push(" *"); + lines.push(" * This file is intentionally a STUB: generated so the node is not dropped from the graph."); + lines.push(" * Surgical AI fills in the target behavior at the marked point below."); + lines.push(" */"); + + // Out-of-scope surgical marker (no member -> id=#stub). + lines.push(`// @solarch:surgical id=${node.id}#stub`); + lines.push(`// out-of-scope: ${kind} "${node.name}" is not deterministically generated in v1`); + if (description) lines.push(`// ${description}`); + + // Edge summary — node's graph connections (deterministic, sorted by name). + const edgeLines = buildEdgeSummary(node, ctx); + if (edgeLines.length > 0) { + lines.push("//"); + lines.push("// edges:"); + for (const el of edgeLines) lines.push(`// ${el}`); + } else { + lines.push("//"); + lines.push("// edges: (none)"); + } + + // Empty placeholder class — not silently dropped; exported. Injectable + // stubs carry @Injectable() (added to module providers -> boot DI). + if (isInjectable) lines.push("@Injectable()"); + lines.push(`export class ${className} {}`); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: filePathFor(node, ctx.graph), + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/** Safely reads node.properties.Description (present on all 12 types but untyped). */ +function readDescription(node: CodeNode): string { + const desc = (node.properties as Record).Description; + return typeof desc === "string" ? desc.trim() : ""; +} + +/* ── Edge summary ───────────────────────────────────────────────────────────── + * Converts outgoing ("-> KIND Name") and incoming ("<- KIND Name") edges to + * one-line summaries. CodeGraph already keeps edges sorted by kind,source,target,id + * so outEdges/inEdges arrive sorted; output is deterministic. + * Unresolved endpoint (missing ref) -> shown as "(?)", NEVER throws. */ +function buildEdgeSummary(node: CodeNode, ctx: EmitterContext): string[] { + const out: string[] = []; + + for (const e of ctx.graph.outEdges(node.id)) { + const tgt = ctx.graph.byId(e.targetNodeId); + out.push(`${e.kind} -> ${describeRef(tgt)}`); + } + for (const e of ctx.graph.inEdges(node.id)) { + const src = ctx.graph.byId(e.sourceNodeId); + out.push(`${describeRef(src)} -> ${e.kind} (incoming)`); + } + return out; +} + +/** Describes an edge endpoint as "KIND Name"; "(?)" when endpoint cannot be resolved. */ +function describeRef(ref: CodeNode | null): string { + if (!ref) return "(?)"; + const name = ref.name || "(unnamed)"; + return `${ref.kindOf()} ${name}`; +} diff --git a/apps/server/src/codegen/emitters/nestjs/surgical-plan.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/surgical-plan.emitter.spec.ts new file mode 100644 index 0000000..f1f7440 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/surgical-plan.emitter.spec.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from "vitest"; +import { emitSurgicalPlan } from "./surgical-plan.emitter"; +import { buildCodeGraph } from "../../ir"; +import { surgicalMarker, notImplemented } from "../../surgical"; +import type { GeneratedFile } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; + +/* ──────────────────────────────────────────────────────────────────────── + * surgical-plan.emitter.spec.ts — SURGICAL_PLAN.md dogrulamasi. + * + * (1) MD iki bolum + kapanis talimati icerir, Ingilizcedir. + * (2) Uretilen .ts dosyalarindaki "@solarch:surgical" marker'lari taranir: + * dosya yolu + imza + throws/deps + "Implement: ..." maddesi listelenir. + * (3) SAF + DETERMINISTIC: ayni girdi -> byte-identical MD. + * ──────────────────────────────────────────────────────────────────────── */ + +const PROJECT = "00000000-0000-4000-8000-000000000000"; +const TAB = "22222222-2222-4222-8222-222222222222"; + +function node(type: StoredNode["type"], id: string, properties: Record): StoredNode { + return { + id, + type, + projectId: PROJECT, + positionX: 0, + positionY: 0, + homeTabId: TAB, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +/** Bir surgical metot govdesi iceren TS dosyasi (gercek emitter ciktisina benzer). */ +function tsFileWithMarker( + path: string, + nodeId: string, + signature: string, + member: string, + opts: { description?: string; throws?: string[]; deps?: string[] } = {}, +): GeneratedFile { + const marker = surgicalMarker({ nodeId, member, ...opts }); + const lines: string[] = []; + lines.push("@Injectable()"); + lines.push("export class Demo {"); + lines.push(` ${signature}`); + for (const ml of marker.split("\n")) lines.push(` ${ml}`); + lines.push(` ${notImplemented("Demo", member)}`); + lines.push(" }"); + lines.push("}"); + const content = lines.join("\n") + "\n"; + return { + path, + content, + language: "typescript", + surgicalMarkers: (content.match(/@solarch:surgical/g) ?? []).length, + }; +} + +const SVC = "10000000-0000-4000-8000-000000000001"; + +/** Fixture graph: tek "users" feature'i (controller + service) -> feature listesi. */ +function fixtureGraph() { + const svc = node("Service", SVC, { ServiceName: "UsersService", Description: "x", Methods: [], Dependencies: [] }); + const ctrl = node("Controller", "10000000-0000-4000-8000-000000000002", { + ControllerName: "UsersController", + Description: "x", + BaseRoute: "users", + Endpoints: [], + }); + return buildCodeGraph([svc, ctrl], []); +} + +describe("emitSurgicalPlan", () => { + it("SURGICAL_PLAN.md uretir (kok yol, markdown, surgicalMarkers 0)", () => { + const file = emitSurgicalPlan([], fixtureGraph()); + expect(file.path).toBe("SURGICAL_PLAN.md"); + expect(file.language).toBe("markdown"); + // MD prose marker ADINI anabilir ama bir surgical BODY degildir; emitter + // surgicalMarkers'i 0'a sabitler -> aggregate surgicalMarkerCount bozulmaz. + expect(file.surgicalMarkers).toBe(0); + expect(file.content.endsWith("\n")).toBe(true); + }); + + it("iki bolum + kapanis talimati icerir (Ingilizce prompt)", () => { + const file = emitSurgicalPlan([], fixtureGraph()); + expect(file.content).toContain("# Surgical Implementation Plan"); + expect(file.content).toContain("## 1. Codebase introduction"); + expect(file.content).toContain("## 2. Surgical implementation plan"); + expect(file.content).toContain("## Instructions"); + // Codebase tanitimi: NestJS + Solarch + mimari. + expect(file.content).toContain("NestJS"); + expect(file.content).toContain("Solarch"); + expect(file.content).toContain("CoreModule"); + expect(file.content).toContain("shared/"); + // Kapanis: yalniz isaretli govdeleri doldur, yapiyi degistirme. + expect(file.content).toContain("Do NOT change any signature"); + expect(file.content).toContain("Do NOT edit any other code"); + // English only (not Turkish) — pasted to user. + const turkishChars = /[\u011F\u00FC\u015F\u0131\u00F6\u00E7\u011E\u00DC\u015E\u0130\u00D6\u00C7]/; + expect(file.content).not.toMatch(turkishChars); + }); + + it("feature listesini graph'tan kurar", () => { + const file = emitSurgicalPlan([], fixtureGraph()); + expect(file.content).toContain("Features (1)"); + expect(file.content).toContain("`users`"); + }); + + it("marker tarar: dosya yolu + imza + Implement + throws + deps listelenir", () => { + const files = [ + tsFileWithMarker( + "src/users/users.service.ts", + SVC, + "async create(dto: CreateUserDto): Promise {", + "create", + { + description: "Create a new user and persist it.", + throws: ["UserNotFoundException"], + deps: ["this.userRepository"], + }, + ), + ]; + const file = emitSurgicalPlan(files, fixtureGraph()); + + // Dosyaya gore grupli baslik. + expect(file.content).toContain("### `src/users/users.service.ts`"); + // Imza (marker'in ust satiri) listelenir. + expect(file.content).toContain("`async create(dto: CreateUserDto): Promise {`"); + // Description -> Implement maddesi. + expect(file.content).toContain("Implement: Create a new user and persist it."); + // throws + deps. + expect(file.content).toContain("Throws: UserNotFoundException"); + expect(file.content).toContain("Available dependencies: this.userRepository"); + // Sayac metni (1 govde). + expect(file.content).toContain("**1** surgical method body"); + }); + + it("cok-satirli imzayi (controller @Body) tek satira birlestirir", () => { + // NestJS controller: imza @Body() parametresiyle birden multilinea yayilir, + // marker'in hemen ustundeki satir yalniz KAPANISTIR (`): Promise {`). + const content = + [ + "@Controller()", + "export class UsersController {", + " @Post()", + " async post(", + " @Body() dto: CreateUserDto,", + " ): Promise {", + " // @solarch:surgical id=n1#post", + " // Handles the POST / endpoint.", + ' throw new Error("NOT_IMPLEMENTED: UsersController.post");', + " }", + "}", + ].join("\n") + "\n"; + const files: GeneratedFile[] = [ + { path: "src/users/users.controller.ts", content, language: "typescript", surgicalMarkers: 1 }, + ]; + const file = emitSurgicalPlan(files, fixtureGraph()); + // Imza tam: metot adi + parametreler + donus tipi tek satirda birlesir. + expect(file.content).toContain("`async post( @Body() dto: CreateUserDto, ): Promise {`"); + // Sadece kapanis parcasi ("): Promise {") TEK BASINA listelenmemeli. + expect(file.content).not.toContain("**`): Promise {`**"); + }); + + it("description NONESA notr Implement ipucu uretir", () => { + const files = [tsFileWithMarker("src/users/users.service.ts", SVC, "list(): User[] {", "list")]; + const file = emitSurgicalPlan(files, fixtureGraph()); + expect(file.content).toContain("Implement: the body of `list`"); + }); + + it("birden cok marker -> dosya/uye sirasinda deterministik gruplama", () => { + const files: GeneratedFile[] = [ + tsFileWithMarker("src/users/user.repository.ts", "n2", "findByEmail(email: string): Promise {", "findByEmail"), + tsFileWithMarker("src/users/users.service.ts", SVC, "create(): void {", "create"), + ]; + const file = emitSurgicalPlan(files, fixtureGraph()); + // Iki dosya da bolumlenir; sayim 2. + expect(file.content).toContain("### `src/users/user.repository.ts`"); + expect(file.content).toContain("### `src/users/users.service.ts`"); + expect(file.content).toContain("**2** surgical method bodies"); + }); + + it("marker yoksa: implement edilecek bir sey olmadigini bildirir", () => { + const files = [ + { path: "src/main.ts", content: "console.log('x');\n", language: "typescript", surgicalMarkers: 0 } as GeneratedFile, + ]; + const file = emitSurgicalPlan(files, fixtureGraph()); + expect(file.content).toContain("No `@solarch:surgical` markers were found"); + expect(file.content).toContain("**0** surgical method bodies"); + }); + + it("SQL/JSON/markdown dosyalarini taramaz (yalniz typescript)", () => { + const files: GeneratedFile[] = [ + { path: "migrations/001_create_users.sql", content: "-- @solarch:surgical id=x#y\n", language: "sql", surgicalMarkers: 0 }, + { path: "package.json", content: "{}\n", language: "json", surgicalMarkers: 0 }, + ]; + const file = emitSurgicalPlan(files, fixtureGraph()); + // SQL icindeki sahte marker taranmaz -> "nothing to implement". + expect(file.content).toContain("No `@solarch:surgical` markers were found"); + }); + + it("DETERMINISM: ayni girdi -> byte-identical MD", () => { + const build = () => + emitSurgicalPlan( + [ + tsFileWithMarker("src/users/users.service.ts", SVC, "create(): void {", "create", { + description: "do it", + deps: ["this.repo"], + }), + ], + fixtureGraph(), + ).content; + expect(build()).toBe(build()); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/surgical-plan.emitter.ts b/apps/server/src/codegen/emitters/nestjs/surgical-plan.emitter.ts new file mode 100644 index 0000000..2521809 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/surgical-plan.emitter.ts @@ -0,0 +1,295 @@ +import type { GeneratedFile } from "../../types"; +import type { CodeGraph } from "../../ir"; + +/* ──────────────────────────────────────────────────────────────────────── + * surgical-plan.emitter.ts — SURGICAL_PLAN.md (proje KOKUNDE). + * + * Constructor (deterministik) yalniz ISKELET uretir; metot govdeleri + * "@solarch:surgical" marker'lariyla isaretli BOS algoritma alanlaridir. Bu + * emitter, MONTAJ AFTERSI tum dosyalari tarayip tek bir INGILIZCE Markdown + * dosyasi uretir: bir AI'a OLDUGU GIBI yapistirilabilen bir PROMPT. + * + * Iki bolum: + * (1) CODEBASE INTRODUCTION — bu kod Solarch-uretimi NestJS+TS'tir; mimari + * (feature-based modules + CoreModule + shared/) + stack + proje yapisi + * (feature listesi graph'tan) anlatilir. + * (2) SURGICAL IMPLEMENTATION PLAN — uretilen TUM .ts dosyalarindaki + * "// @solarch:surgical ..." marker'lari taranir; her biri icin dosya + * yolu + uzerindeki metot imzasi + throws/deps + "Implement: ..." maddesi + * (feature/dosyaya gore grupli, deterministik sirali) listelenir. + * + * SAF + DETERMINISTIC: yalniz `files` (path'e sirali verilir) + `graph` okunur; + * timestamp/random yok. Ayni graph -> byte-identical MD. MD'NIN KENDISI marker + * TASIMAZ (surgicalMarkers: 0) — yalniz marker'lari BETIMLEYEN duz metindir. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Marker satirindan ayristirilan tek bir surgical alan. */ +interface SurgicalSite { + /** GeneratedFile.path (montaj sonrasi nihai yol, or. "src/users/users.service.ts"). */ + filePath: string; + /** Marker'daki `id=#` -> member (metot/uye adi). */ + member: string; + /** Marker'in hemen USTUNDEKI kod satiri (metot imzasi), trim'li. */ + signature: string; + /** Marker description satirlari (id/throws/deps DISINDA kalan `// ...` satirlari). */ + description: string[]; + /** `// throws: A, B` -> ["A", "B"]. */ + throws: string[]; + /** `// deps: this.x, this.y` -> ["this.x", "this.y"]. */ + deps: string[]; +} + +const MARKER_RE = /^\s*\/\/\s*@solarch:surgical\s+id=([^#\s]+)#(\S+)\s*$/; +const THROWS_RE = /^\s*\/\/\s*throws:\s*(.+)$/; +const DEPS_RE = /^\s*\/\/\s*deps:\s*(.+)$/; +const COMMENT_RE = /^\s*\/\/\s?(.*)$/; + +/** + * SURGICAL_PLAN.md uretir. `files` montaj sonrasi TUM dosyalardir (path'e gore + * sirali verilmelidir -> tarama deterministik). `graph` feature listesi icin. + */ +export function emitSurgicalPlan(files: GeneratedFile[], graph: CodeGraph): GeneratedFile { + const sites = collectSurgicalSites(files); + const body = + renderIntroduction(graph, sites.length) + "\n" + renderPlan(sites) + "\n" + renderClosing(); + + return { + path: "SURGICAL_PLAN.md", + content: body.endsWith("\n") ? body : body + "\n", + language: "markdown", + // MD marker ICERMEZ; yalniz marker'lari BETIMLER -> surgicalMarkers sayimi + // bozulmasin diye 0. (countSurgicalMarkers KULLANILMAZ: bu metin + // "@solarch:surgical" alt-dizesini icermez; kasitli.) + surgicalMarkers: 0, + }; +} + +/* ── (1) CODEBASE INTRODUCTION ─────────────────────────────────────────────── */ + +function renderIntroduction(graph: CodeGraph, siteCount: number): string { + const features = graph.features().map((f) => f.slug); + const lines: string[] = []; + + lines.push("# Surgical Implementation Plan"); + lines.push(""); + lines.push( + "> Paste this entire file into an AI coding assistant. It is a complete, self-contained prompt: section 1 explains the codebase, section 2 is the exact list of method bodies you must implement.", + ); + lines.push(""); + lines.push("## 1. Codebase introduction"); + lines.push(""); + lines.push( + "This is a **NestJS + TypeScript** backend generated by **Solarch** from an architecture diagram. The project skeleton is deterministic and complete: modules, controllers, services, repositories, DTOs, entities, database migrations and dependency injection are already wired and compile out of the box.", + ); + lines.push(""); + lines.push( + "What is **not** implemented is the business logic. Every method body that requires an algorithm is left empty and marked with a `// @solarch:surgical ...` comment, followed by `throw new Error(\"NOT_IMPLEMENTED: ...\");`. These are the only places you may write code.", + ); + lines.push(""); + lines.push("### Architecture"); + lines.push(""); + lines.push( + "- **Feature-based modules.** Each feature lives in its own folder under `src//` with a single `.module.ts` that registers its controllers, providers and TypeORM entities.", + ); + lines.push( + "- **CoreModule** (`src/core/core.module.ts`) holds global infrastructure: configuration (`ConfigModule.forRoot` with a Joi validation schema), the TypeORM data source (`TypeOrmModule.forRootAsync`) and the pino logger. `AppModule` stays thin: it imports `CoreModule` and the feature modules only.", + ); + lines.push( + "- **shared/** holds cross-cutting building blocks (guards, decorators, and a global exception filter in `src/shared/filters/all-exceptions.filter.ts`).", + ); + lines.push( + "- **Deterministic skeleton.** Class names, file paths, imports and DI are generated; the algorithm bodies marked with `@solarch:surgical` are the surgical fields you fill in.", + ); + lines.push(""); + lines.push("### Stack"); + lines.push(""); + lines.push("- NestJS (modules, controllers, providers, dependency injection)"); + lines.push("- TypeScript"); + lines.push("- TypeORM (entities + SQL migrations under `migrations/` and `src/migrations/`)"); + lines.push("- class-validator / class-transformer (DTO validation)"); + lines.push("- @nestjs/config + Joi (environment validation)"); + lines.push("- pino (structured logging)"); + lines.push(""); + lines.push("### Project structure"); + lines.push(""); + if (features.length > 0) { + lines.push(`Features (${features.length}), each under \`src//\`:`); + lines.push(""); + for (const slug of features) lines.push(`- \`${slug}\``); + } else { + lines.push("No feature folders were inferred from the graph."); + } + lines.push(""); + lines.push( + `There are **${siteCount}** surgical method ${plural(siteCount, "body", "bodies")} to implement, listed in section 2.`, + ); + lines.push(""); + return lines.join("\n"); +} + +/* ── (2) SURGICAL IMPLEMENTATION PLAN ──────────────────────────────────────── */ + +function renderPlan(sites: SurgicalSite[]): string { + const lines: string[] = []; + lines.push("## 2. Surgical implementation plan"); + lines.push(""); + + if (sites.length === 0) { + lines.push( + "No `@solarch:surgical` markers were found. There is nothing to implement — the generated skeleton is complete.", + ); + lines.push(""); + return lines.join("\n"); + } + + // Dosyaya gore grupla (sites zaten filePath, sonra member sirasinda gelir). + const byFile = new Map(); + for (const s of sites) { + const arr = byFile.get(s.filePath); + if (arr) arr.push(s); + else byFile.set(s.filePath, [s]); + } + // Dosya yollari deterministik sirada (files path'e sirali verilir; yine de garanti). + const filePaths = [...byFile.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + + for (const filePath of filePaths) { + lines.push(`### \`${filePath}\``); + lines.push(""); + for (const s of byFile.get(filePath)!) { + lines.push(`- **\`${s.signature}\`**`); + const impl = s.description.length > 0 ? s.description.join(" ") : defaultImplHint(s.member); + lines.push(` - Implement: ${impl}`); + if (s.throws.length > 0) { + lines.push(` - Throws: ${s.throws.join(", ")}`); + } + if (s.deps.length > 0) { + lines.push(` - Available dependencies: ${s.deps.join(", ")}`); + } + } + lines.push(""); + } + return lines.join("\n"); +} + +function renderClosing(): string { + const lines: string[] = []; + lines.push("## Instructions"); + lines.push(""); + lines.push( + "Fill in only the marked method bodies (replace each `// @solarch:surgical ...` marker and its `throw new Error(\"NOT_IMPLEMENTED: ...\")` line with a real implementation). Do NOT change any signature, decorator, import or structure. Do NOT edit any other code.", + ); + lines.push(""); + return lines.join("\n"); +} + +/* ── Marker taramasi ───────────────────────────────────────────────────────── */ + +/** + * Uretilen TUM .ts dosyalarini satir satir tarayip surgical alanlari cikarir. + * Deterministik: dosyalar verilen sirada (path'e sirali), her dosya icinde + * marker satir sirasinda islenir. + */ +function collectSurgicalSites(files: GeneratedFile[]): SurgicalSite[] { + const sites: SurgicalSite[] = []; + for (const f of files) { + // Yalniz TypeScript kaynak dosyalarini tara (SQL/JSON/MD marker tasimaz). + if (f.language !== "typescript") continue; + const lines = f.content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const m = MARKER_RE.exec(lines[i]); + if (!m) continue; + const member = m[2]; + // Imza: marker'in hemen USTUNDEKI kod satiri(lari). Cok-satirli imzalar + // (or. NestJS controller'da @Body() dekoratorlu parametreler) tek satirda + // KAPANIR (`): Promise {`); o yuzden parantezler dengeleninceye dek + // YUKARI dogru satir topla, sonra tek satira birlestir. + const signature = reconstructSignature(lines, i); + // Marker'in ALTINDAKI description/throws/deps satirlarini topla. + const description: string[] = []; + const throwsList: string[] = []; + const depsList: string[] = []; + let j = i + 1; + for (; j < lines.length; j++) { + const line = lines[j]; + const th = THROWS_RE.exec(line); + if (th) { + for (const t of splitList(th[1])) throwsList.push(t); + continue; + } + const dp = DEPS_RE.exec(line); + if (dp) { + for (const d of splitList(dp[1])) depsList.push(d); + continue; + } + const cm = COMMENT_RE.exec(line); + if (cm) { + const text = cm[1].trim(); + if (text.length > 0) description.push(text); + continue; + } + // Ilk yorum-olmayan satir -> marker blogu bitti. + break; + } + sites.push({ filePath: f.path, member, signature, description, throws: throwsList, deps: depsList }); + i = j - 1; // blogu atla (ic dongu tukettigi satirlari tekrar tarama). + } + } + return sites; +} + +/* ── Yardimcilar ───────────────────────────────────────────────────────────── */ + +/** + * Marker'in (markerIdx) hemen USTUNDEKI metot imzasini dondurur. Tek satirlik + * imzalar oldugu gibi gelir. NestJS controller imzalari gibi COK satirlilar + * marker'in ustunde KAPANIS satiriyla (`): Promise {`) biter; bu yuzden + * parantezler dengeleninceye dek (ya da en cok birkac satir) YUKARI dogru + * satir toplar ve aralarindaki bosluklari sadelestirerek TEK satira birlestirir. + * Deterministik: yalniz verilen satir dizisine bakar. + */ +function reconstructSignature(lines: string[], markerIdx: number): string { + if (markerIdx <= 0) return ""; + const above = lines[markerIdx - 1].trim(); + if (above.length === 0) return ""; + + // Ust satir zaten dengeli parantezli bir imza ise (tek-satir), oldugu gibi al. + let open = countChar(above, "("); + let close = countChar(above, ")"); + if (close <= open) return above; + + // Kapanis fazla -> acilis ust satirlarda. Dengeleninceye dek yukari topla. + const collected: string[] = [above]; + // Guvenlik siniri: cok uzun bir blokta sonsuza yurume (imzalar kisadir). + const MAX_LINES = 40; + for (let k = markerIdx - 2; k >= 0 && collected.length < MAX_LINES; k--) { + const t = lines[k].trim(); + collected.unshift(t); + open += countChar(t, "("); + close += countChar(t, ")"); + if (open >= close) break; + } + // Tek satira birlestir; coklu bosluklari sadelestir. + return collected.join(" ").replace(/\s+/g, " ").trim(); +} + +function countChar(s: string, ch: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) if (s[i] === ch) n++; + return n; +} + +function splitList(raw: string): string[] { + return raw + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +/** Marker'da description NONESA uretilecek notr Ingilizce ipucu. */ +function defaultImplHint(member: string): string { + return `the body of \`${member}\` as required by its signature and the architecture.`; +} + +function plural(n: number, singular: string, pluralForm: string): string { + return n === 1 ? singular : pluralForm; +} diff --git a/apps/server/src/codegen/emitters/nestjs/table.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/table.emitter.spec.ts new file mode 100644 index 0000000..1cdfbbc --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/table.emitter.spec.ts @@ -0,0 +1,289 @@ +import { describe, it, expect } from "vitest"; +import { emitTable } from "./table.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +function tableNode(properties: Record, id: string): StoredNode { + return { + id, + type: "Table", + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function ctxFor(...nodes: StoredNode[]): { ctx: EmitterContext } { + const graph = buildCodeGraph(nodes, []); + return { ctx: { graph, target: "nestjs" } }; +} + +const USER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const ORDER_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + +const USER_TABLE = { + // TableName fiziksel ad olarak alinir (cogullanmaz) -> dogal cogul yazilir. + TableName: "users", + Description: "Application user", + Columns: [ + { Name: "Id", DataType: "INT", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: true }, + { Name: "Email", DataType: "VARCHAR", Length: 320, IsPrimaryKey: false, IsNotNull: true, IsUnique: true }, + { Name: "FullName", DataType: "VARCHAR", Length: 120, IsPrimaryKey: false, IsNotNull: false, IsUnique: false }, + { Name: "Balance", DataType: "DECIMAL", Precision: 12, Scale: 2, IsPrimaryKey: false, IsNotNull: true, IsUnique: false, DefaultValue: "0" }, + { Name: "IsActive", DataType: "BOOLEAN", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, DefaultValue: "true" }, + ], + ForeignKeys: [], + UniqueConstraints: [], + CheckConstraints: [{ Expression: "balance >= 0" }], + Indexes: [{ IndexName: "idx_user_email", Columns: ["Email"], Type: "BTree", IsUnique: true }], +}; + +const ORDER_TABLE = { + TableName: "orders", + Description: "Order kaydi", + Columns: [ + { Name: "Id", DataType: "BIGINT", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: true }, + { Name: "UserId", DataType: "INT", IsPrimaryKey: false, IsNotNull: true, IsUnique: false }, + { Name: "Total", DataType: "DECIMAL", Precision: 10, Scale: 2, IsPrimaryKey: false, IsNotNull: true, IsUnique: false }, + { Name: "Payload", DataType: "JSON", IsPrimaryKey: false, IsNotNull: false, IsUnique: false }, + { Name: "CreatedAt", DataType: "DATETIME", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, DefaultValue: "now()" }, + ], + ForeignKeys: [ + { + Columns: ["UserId"], + ReferencesTable: "users", + ReferencesColumns: ["Id"], + OnDelete: "CASCADE", + OnUpdate: "NO_ACTION", + }, + ], + UniqueConstraints: [], + CheckConstraints: [], + Indexes: [{ IndexName: "idx_order_user", Columns: ["UserId"], Type: "Hash" }], +}; + +describe("emitTable (Table -> Postgres migration SQL)", () => { + it("tam tablo (PK + unique + check + index) — snapshot", () => { + const node = tableNode(USER_TABLE, USER_ID); + const { ctx } = ctxFor(node); + const [file] = emitTable(ctx.graph.byId(node.id)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "-- Application user + + CREATE TABLE "users" ( + "id" SERIAL NOT NULL, + "email" VARCHAR(320) NOT NULL UNIQUE, + "full_name" VARCHAR(120), + "balance" DECIMAL(12, 2) NOT NULL DEFAULT 0, + "is_active" BOOLEAN NOT NULL DEFAULT true, + PRIMARY KEY ("id"), + CONSTRAINT "ck_users_balance_0" CHECK (balance >= 0) + ); + + CREATE UNIQUE INDEX "idx_user_email" ON "users" ("email"); + ", + "language": "sql", + "path": "migrations/001_create_users.sql", + "surgicalMarkers": 0, + } + `); + }); + + it("FK referans cozumu + migration sirasi: referans edilen tablo once gelir", () => { + const user = tableNode(USER_TABLE, USER_ID); + const order = tableNode(ORDER_TABLE, ORDER_ID); + const { ctx } = ctxFor(user, order); + + const [userFile] = emitTable(ctx.graph.byId(USER_ID)!, ctx); + const [orderFile] = emitTable(ctx.graph.byId(ORDER_ID)!, ctx); + + // User'a FK ile bagimli olan Order, topolojide sonra (002) gelir. + expect(userFile.path).toBe("migrations/001_create_users.sql"); + expect(orderFile.path).toBe("migrations/002_create_orders.sql"); + + // FK tum tablodan sonra ALTER TABLE ile, hedef tablo "users" cozulmus. + expect(orderFile.content).toContain( + 'ALTER TABLE "orders" ADD CONSTRAINT "fk_orders_user_id" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION;', + ); + // BIGINT + AutoIncrement -> BIGSERIAL. + expect(orderFile.content).toContain('"id" BIGSERIAL NOT NULL'); + // JSON -> JSONB; DATETIME -> TIMESTAMP. + expect(orderFile.content).toContain('"payload" JSONB'); + expect(orderFile.content).toContain('"created_at" TIMESTAMP NOT NULL DEFAULT now()'); + // Hash index -> USING HASH. + expect(orderFile.content).toContain('CREATE INDEX "idx_order_user" ON "orders" USING HASH ("user_id");'); + }); + + it("composite PK (PrimaryKey.Columns) + UNIQUE constraint", () => { + const node = tableNode( + { + TableName: "user_roles", + Description: "User-rol eslesmesi", + Columns: [ + { Name: "UserId", DataType: "INT", IsPrimaryKey: false, IsNotNull: true, IsUnique: false }, + { Name: "RoleId", DataType: "INT", IsPrimaryKey: false, IsNotNull: true, IsUnique: false }, + ], + PrimaryKey: { Columns: ["UserId", "RoleId"] }, + ForeignKeys: [], + UniqueConstraints: [{ Columns: ["UserId", "RoleId"] }], + CheckConstraints: [], + Indexes: [], + }, + "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + ); + const { ctx } = ctxFor(node); + const [file] = emitTable(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain('PRIMARY KEY ("user_id", "role_id")'); + expect(file.content).toContain('CONSTRAINT "uq_user_roles_user_id_role_id" UNIQUE ("user_id", "role_id")'); + }); + + it("GENERATED kolon + tek-kolon UNIQUE kolon dekoratoru", () => { + const node = tableNode( + { + TableName: "Invoice", + Description: "Fatura", + Columns: [ + { Name: "Id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: false }, + { Name: "Net", DataType: "DECIMAL", Precision: 10, Scale: 2, IsPrimaryKey: false, IsNotNull: true, IsUnique: false }, + { Name: "Tax", DataType: "DECIMAL", Precision: 10, Scale: 2, IsPrimaryKey: false, IsNotNull: true, IsUnique: false }, + { + Name: "Gross", + DataType: "DECIMAL", + Precision: 10, + Scale: 2, + IsPrimaryKey: false, + IsNotNull: false, + IsUnique: false, + IsGenerated: true, + GeneratedExpression: "net + tax", + }, + ], + ForeignKeys: [], + UniqueConstraints: [], + CheckConstraints: [], + Indexes: [], + }, + "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + ); + const { ctx } = ctxFor(node); + const [file] = emitTable(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain('"id" UUID NOT NULL'); + expect(file.content).toContain('"gross" DECIMAL(10, 2) GENERATED ALWAYS AS (net + tax) STORED'); + }); + + it("edge-case: kayip FK ref + bos koleksiyonlar -> throw yok, FK ham isimden turer", () => { + const node = tableNode( + { + TableName: "comments", + Description: "Yorum", + Columns: [ + { Name: "Id", DataType: "INT", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: true }, + { Name: "PostId", DataType: "INT", IsPrimaryKey: false, IsNotNull: true, IsUnique: false }, + ], + // ForeignKeys/UniqueConstraints/CheckConstraints/Indexes BILEREK eksik (Zod default'suz ham node). + ForeignKeys: [ + { Columns: ["PostId"], ReferencesTable: "posts", ReferencesColumns: ["Id"] }, + ], + }, + "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + ); + const { ctx } = ctxFor(node); + expect(() => emitTable(ctx.graph.byId(node.id)!, ctx)).not.toThrow(); + const [file] = emitTable(ctx.graph.byId(node.id)!, ctx); + // Hedef "posts" graph'ta yok -> ham isim fiziksel ad sayilir (cogullanmaz), throw yok. + expect(file.content).toContain( + 'ALTER TABLE "comments" ADD CONSTRAINT "fk_comments_post_id" FOREIGN KEY ("post_id") REFERENCES "posts" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION;', + ); + // Bos Index/Unique/Check -> ekstra satir yok. + expect(file.content).not.toContain("CREATE INDEX"); + expect(file.content).not.toContain("UNIQUE ("); + expect(file.content.endsWith(";\n")).toBe(true); + }); + + it("dil 'sql', yol migrations/ altinda, content ends with single newline", () => { + const node = tableNode(USER_TABLE, USER_ID); + const { ctx } = ctxFor(node); + const [file] = emitTable(ctx.graph.byId(node.id)!, ctx); + expect(file.language).toBe("sql"); + expect(file.path).toMatch(/^migrations\/\d{3}_create_users\.sql$/); + expect(file.content.endsWith("\n")).toBe(true); + expect(file.content.endsWith("\n\n")).toBe(false); + }); + + it("DETERMINISM: same node twice -> byte-identical", () => { + const node = tableNode(ORDER_TABLE, ORDER_ID); + const { ctx } = ctxFor(node); + const a = emitTable(ctx.graph.byId(node.id)!, ctx)[0].content; + const b = emitTable(ctx.graph.byId(node.id)!, ctx)[0].content; + expect(a).toBe(b); + }); + + it("surgicalMarkers SQL'de 0", () => { + const node = tableNode(ORDER_TABLE, ORDER_ID); + const { ctx } = ctxFor(node); + const [file] = emitTable(ctx.graph.byId(node.id)!, ctx); + expect(file.surgicalMarkers).toBe(0); + }); + + /* ── ENUM kolonu -> VARCHAR + CHECK (entity ile tutarli) ───────────────── + * #56: entity @Column({type:"enum"}) ama migration TEXT -> tutarsiz. Karar: + * varchar + CHECK. Migration enum kolonunu VARCHAR yapar ve degerleri CHECK ile + * kisitlar (DB-seviyesi dogrulama); native CREATE TYPE NONE (diyagram evrilince + * migration kâbusu olmasin). Degerler EnumRef -> Enum node Values (Value ?? Key). */ + it("ENUM kolonu -> VARCHAR + CHECK (col IN ...), TEXT/CREATE TYPE degil", () => { + const enumNode: StoredNode = { + id: "e0000000-0000-4000-8000-000000000001", + type: "Enum", + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties: { + Name: "OrderStatus", + Description: "Order status", + BackingType: "string", + Values: [ + { Key: "PENDING", Value: "pending" }, + { Key: "CONFIRMED", Value: "confirmed" }, + { Key: "CANCELLED", Value: "cancelled" }, + ], + }, + }; + const table = tableNode( + { + TableName: "orders", + Description: "Order", + Columns: [ + { Name: "Id", DataType: "BIGINT", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: true }, + { Name: "Status", DataType: "ENUM", EnumRef: "OrderStatus", IsPrimaryKey: false, IsNotNull: true, IsUnique: false }, + ], + ForeignKeys: [], + UniqueConstraints: [], + CheckConstraints: [], + Indexes: [], + }, + ORDER_ID, + ); + const { ctx } = ctxFor(table, enumNode); + const [file] = emitTable(ctx.graph.byId(ORDER_ID)!, ctx); + // Kolon VARCHAR (TEXT/native-enum NOT). + expect(file.content).toMatch(/"status" VARCHAR/); + expect(file.content).not.toMatch(/"status" TEXT/); + expect(file.content).not.toContain("CREATE TYPE"); + // CHECK constraint enum backing degerleriyle (Value ?? Key). + expect(file.content).toMatch( + /CHECK \("status" IN \('pending', 'confirmed', 'cancelled'\)\)/, + ); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/table.emitter.ts b/apps/server/src/codegen/emitters/nestjs/table.emitter.ts new file mode 100644 index 0000000..adb3f39 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/table.emitter.ts @@ -0,0 +1,339 @@ +import type { EmitterContext, GeneratedFile, NodeEmitter } from "../../types"; +import { propsOf, type CodeNode } from "../../ir"; +import { filePathFor, snakeCase, tableSqlName } from "../../naming"; +import { countSurgicalMarkers } from "../../surgical"; + +/* ──────────────────────────────────────────────────────────────────────── + * table.emitter.ts — TableNode -> Postgres migration SQL (DDL). + * + * Sozlesme (enum.emitter.ts kanonik referansi ile birebir tutarli): + * - default export NONE; named `export const emitTable: NodeEmitter`. + * - SAF fonksiyon: (node, ctx) -> GeneratedFile[]. I/O yok, throw yok. + * - Yol her zaman filePathFor(node, ctx.graph) ile (hardcode YASAK). NNN + * migration sirasi graph.migrationIndexOf uzerinden filePathFor icinde cozulur; + * bu nedenle emitter tum Table setini ctx.graph uzerinden gorur. + * - Icerik DETERMINISTIC: koleksiyonlar verilen sirada, timestamp/random yok. + * - Icerik tek "\n" ile biter. + * - surgicalMarkers countSurgicalMarkers(content) ile sayilir (SQL'de 0). + * + * TableNode -> migrations/NNN_create_.sql: + * CREATE TABLE ( + * , + * PRIMARY KEY (...), (Column.IsPrimaryKey veya PrimaryKey.Columns) + * CONSTRAINT UNIQUE (...), (UniqueConstraints) + * CONSTRAINT CHECK (...) (CheckConstraints) + * ); + * CREATE [UNIQUE] INDEX ON
    [USING ...] (...) [WHERE ...]; (Indexes) + * ALTER TABLE
    ADD CONSTRAINT FOREIGN KEY (...) REFERENCES ...; (ForeignKeys) + * + * FK'lar TUM tablolardan sonra gelmeli (sira sorunu) — codegen.service migration + * dosyalarini NNN'e gore siralar, FK'lar her tablonun kendi dosyasinin sonunda + * ALTER TABLE ile eklenir; referans edilen tablo migration topolojisinde once + * (daha dusuk NNN) gelir, boylece calistirma sirasinda hedef tablo zaten vardir. + * ──────────────────────────────────────────────────────────────────────── */ + +type Column = { + Name: string; + DataType: string; + Length?: number; + Precision?: number; + Scale?: number; + IsPrimaryKey: boolean; + IsNotNull: boolean; + IsUnique: boolean; + AutoIncrement: boolean; + DefaultValue?: string; + Comment?: string; + EnumRef?: string; + IsGenerated?: boolean; + GeneratedExpression?: string; +}; + +type ForeignKey = { + Name?: string; + Columns: string[]; + ReferencesTable: string; + ReferencesColumns: string[]; + OnDelete?: string; + OnUpdate?: string; +}; + +type Index = { + IndexName: string; + Columns: string[]; + Type?: string; + IsUnique?: boolean; + IsPartial?: boolean; + WhereClause?: string; +}; + +type UniqueConstraint = { Name?: string; Columns: string[] }; +type CheckConstraint = { Name?: string; Expression: string }; + +export const emitTable: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = propsOf<"Table">(node); + // TableName fiziksel ad olarak alinir (tekrar cogullanmaz). model.emitter ile + // TEK SOURCE: tableSqlName -> entity @Entity adi bu migration adiyla ayni kalir. + const tableName = tableSqlName(node.name); + + const columns = (props.Columns ?? []) as Column[]; + const foreignKeys = (props.ForeignKeys ?? []) as ForeignKey[]; + const uniques = (props.UniqueConstraints ?? []) as UniqueConstraint[]; + const checks = (props.CheckConstraints ?? []) as CheckConstraint[]; + const indexes = (props.Indexes ?? []) as Index[]; + + const blocks: string[] = []; + + // Ust aciklama (deterministik). + if (props.Description) { + blocks.push(`-- ${props.Description}`); + } + + // ── CREATE TABLE govdesi ────────────────────────────────────────────── + const inner: string[] = []; + + for (const col of columns) { + inner.push(` ${renderColumn(col)}`); + } + + const pkColumns = resolvePrimaryKey(columns, props.PrimaryKey?.Columns); + if (pkColumns.length > 0) { + inner.push(` PRIMARY KEY (${pkColumns.map(quoteIdent).join(", ")})`); + } + + for (const uc of uniques) { + const rawCols = uc.Columns ?? []; + const cols = rawCols.map((c) => quoteIdent(snakeCase(c))).join(", "); + if (cols.length === 0) continue; // kayip kolon -> satiri atla + const name = uc.Name ?? defaultUniqueName(tableName, rawCols); + inner.push(` CONSTRAINT ${quoteIdent(name)} UNIQUE (${cols})`); + } + + for (const cc of checks) { + if (!cc.Expression || cc.Expression.trim().length === 0) continue; + const name = cc.Name ?? defaultCheckName(tableName, cc.Expression); + inner.push(` CONSTRAINT ${quoteIdent(name)} CHECK (${cc.Expression.trim()})`); + } + + // ── ENUM kolonlari icin CHECK constraint (#56: varchar + CHECK) ────────── + // Native CREATE TYPE yerine gecerli degerleri CHECK ile kisitla -> entity'nin + // varchar kolonuyla TUTARLI + DB-seviyesi dogrulama. Degerler EnumRef -> Enum + // node'dan (Value ?? Key). Ref cozulemezse CHECK atlanir (kolon yine VARCHAR). + for (const col of columns) { + const line = enumCheckConstraint(col, tableName, ctx); + if (line) inner.push(` ${line}`); + } + + const createTable = + `CREATE TABLE ${quoteIdent(tableName)} (\n` + inner.join(",\n") + `\n);`; + blocks.push(createTable); + + // ── Indeksler (ayri CREATE INDEX) ───────────────────────────────────── + for (const idx of indexes) { + const line = renderIndex(idx, tableName); + if (line) blocks.push(line); + } + + // ── Foreign Key'ler (TUM tablolardan sonra; ALTER TABLE) ────────────── + for (const fk of foreignKeys) { + const line = renderForeignKey(fk, tableName, ctx); + if (line) blocks.push(line); + } + + const body = blocks.join("\n\n") + "\n"; + + const file: GeneratedFile = { + path: filePathFor(node, ctx.graph), + content: body, + language: "sql", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/* ── Kolon uretimi ──────────────────────────────────────────────────────── */ +function renderColumn(col: Column): string { + const parts: string[] = [quoteIdent(snakeCase(col.Name)), sqlType(col)]; + + // AUTO_INCREMENT zaten SERIAL/BIGSERIAL'a cevrildi (sqlType icinde); DEFAULT eklemeyiz. + const isSerial = col.AutoIncrement === true; + + if (col.IsGenerated === true && col.GeneratedExpression && col.GeneratedExpression.trim().length > 0) { + parts.push(`GENERATED ALWAYS AS (${col.GeneratedExpression.trim()}) STORED`); + } + if (col.IsNotNull === true) { + parts.push("NOT NULL"); + } + if (col.IsUnique === true) { + parts.push("UNIQUE"); + } + if (!isSerial && col.DefaultValue !== undefined && col.DefaultValue !== "") { + parts.push(`DEFAULT ${col.DefaultValue}`); + } + return parts.join(" "); +} + +/** DataType -> Postgres SQL tipi (Length/Precision/Scale + AutoIncrement). */ +function sqlType(col: Column): string { + const dt = (col.DataType ?? "").toUpperCase(); + if (col.AutoIncrement === true) { + return dt === "BIGINT" ? "BIGSERIAL" : "SERIAL"; + } + switch (dt) { + case "INT": + return "INTEGER"; + case "BIGINT": + return "BIGINT"; + case "VARCHAR": + return col.Length && col.Length > 0 ? `VARCHAR(${col.Length})` : "VARCHAR(255)"; + case "TEXT": + return "TEXT"; + case "BOOLEAN": + return "BOOLEAN"; + case "DATETIME": + return "TIMESTAMP"; + case "DATE": + return "DATE"; + case "GUID": + case "UUID": + return "UUID"; + case "FLOAT": + return "DOUBLE PRECISION"; + case "DECIMAL": + if (col.Precision && col.Precision > 0) { + const scale = col.Scale !== undefined ? col.Scale : 0; + return `DECIMAL(${col.Precision}, ${scale})`; + } + return "DECIMAL"; + case "JSON": + return "JSONB"; + case "ENUM": + // ENUM kolonu -> VARCHAR (entity de varchar; tutarlilik #56). Gecerli degerler + // ayrica CHECK constraint ile kisitlanir (enumCheckConstraint, emitTable'da). + // Native CREATE TYPE uretilmez (diyagram evrilince ALTER TYPE kâbusu olmasin). + return "VARCHAR(255)"; + default: + return dt.length > 0 ? dt : "TEXT"; + } +} + +/** PK kolonlarini cozer: once PrimaryKey.Columns (composite), yoksa + * Column.IsPrimaryKey olan kolonlar (verilen sirada). snake_case'lenir. */ +function resolvePrimaryKey(columns: Column[], composite?: string[]): string[] { + if (composite && composite.length > 0) { + return composite.map((c) => snakeCase(c)); + } + return columns.filter((c) => c.IsPrimaryKey === true).map((c) => snakeCase(c.Name)); +} + +/* ── Indeks uretimi ─────────────────────────────────────────────────────── */ +function renderIndex(idx: Index, tableName: string): string | null { + const cols = (idx.Columns ?? []).map((c) => quoteIdent(snakeCase(c))); + if (cols.length === 0) return null; // kayip kolon -> atla + const unique = idx.IsUnique === true ? "UNIQUE " : ""; + const using = indexUsing(idx.Type); + const name = quoteIdent(idx.IndexName); + const table = quoteIdent(tableName); + const where = + idx.WhereClause && idx.WhereClause.trim().length > 0 ? ` WHERE ${idx.WhereClause.trim()}` : ""; + return `CREATE ${unique}INDEX ${name} ON ${table}${using} (${cols.join(", ")})${where};`; +} + +/** Indeks tipi -> Postgres USING ifadesi (BTree varsayilan; atlanir). */ +function indexUsing(type?: string): string { + switch ((type ?? "BTree").toLowerCase()) { + case "hash": + return " USING HASH"; + case "gin": + return " USING GIN"; + case "gist": + return " USING GIST"; + case "btree": + default: + return ""; + } +} + +/* ── Foreign key uretimi (ALTER TABLE; tum tablolardan sonra) ───────────── */ +function renderForeignKey( + fk: ForeignKey, + tableName: string, + ctx: { graph: { resolveRef: (kind: "Table", name: string) => CodeNode | null } }, +): string | null { + const cols = (fk.Columns ?? []).map((c) => quoteIdent(snakeCase(c))); + const refCols = (fk.ReferencesColumns ?? []).map((c) => quoteIdent(snakeCase(c))); + if (cols.length === 0 || refCols.length === 0) return null; // eksik kolon -> atla + + // Hedef tablo node'unu coz; bulunamazsa ham isimden turet (THROW ETMEZ). + // Fiziksel ad tek kaynaktan (tableSqlName) — referans edilen tablonun + // CREATE TABLE adiyla birebir ayni (cogullama NONE). + const refNode = ctx.graph.resolveRef("Table", fk.ReferencesTable); + const refTable = refNode ? tableSqlName(refNode.name) : tableSqlName(fk.ReferencesTable); + + const name = fk.Name ?? defaultForeignKeyName(tableName, fk.Columns); + const onDelete = fkAction(fk.OnDelete); + const onUpdate = fkAction(fk.OnUpdate); + + return ( + `ALTER TABLE ${quoteIdent(tableName)} ADD CONSTRAINT ${quoteIdent(name)} ` + + `FOREIGN KEY (${cols.join(", ")}) ` + + `REFERENCES ${quoteIdent(refTable)} (${refCols.join(", ")}) ` + + `ON DELETE ${onDelete} ON UPDATE ${onUpdate};` + ); +} + +/** FK_ACTION enum -> SQL ifadesi (SET_NULL -> "SET NULL", NO_ACTION -> "NO ACTION"). */ +function fkAction(action?: string): string { + switch ((action ?? "NO_ACTION").toUpperCase()) { + case "CASCADE": + return "CASCADE"; + case "RESTRICT": + return "RESTRICT"; + case "SET_NULL": + return "SET NULL"; + case "NO_ACTION": + default: + return "NO ACTION"; + } +} + +/* ── Deterministik varsayilan constraint adlari ─────────────────────────── */ +function defaultUniqueName(tableName: string, columns: string[]): string { + return `uq_${tableName}_${columns.map((c) => snakeCase(c)).join("_")}`; +} + +/** ENUM kolonu icin CHECK constraint satiri: degerler EnumRef -> Enum node'dan + * (Value ?? Key, enum.emitter ile AYNI backing). Ref cozulemez/deger yoksa null + * (CHECK uretilmez; kolon yine VARCHAR). Degerler SQL-escape edilir (' -> ''). */ +function enumCheckConstraint(col: Column, tableName: string, ctx: EmitterContext): string | null { + if ((col.DataType ?? "").toUpperCase() !== "ENUM" || !col.EnumRef) return null; + const enumNode = ctx.graph.resolveRef("Enum", col.EnumRef); + if (!enumNode) return null; + const values = propsOf<"Enum">(enumNode).Values ?? []; + const backing = values.map((v) => (v.Value !== undefined && v.Value !== "" ? v.Value : v.Key)); + if (backing.length === 0) return null; + const colName = snakeCase(col.Name); + const name = `ck_${tableName}_${colName}_enum`; + const list = backing.map((v) => `'${v.replace(/'/g, "''")}'`).join(", "); + return `CONSTRAINT ${quoteIdent(name)} CHECK (${quoteIdent(colName)} IN (${list}))`; +} + +function defaultCheckName(tableName: string, expression: string): string { + // Ifadeden ciplak kimlik turet: harf/rakam disi -> "_", sikistirilir. + const slug = expression + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 32); + return `ck_${tableName}_${slug.length > 0 ? slug : "check"}`; +} + +function defaultForeignKeyName(tableName: string, columns: string[]): string { + return `fk_${tableName}_${columns.map((c) => snakeCase(c)).join("_")}`; +} + +/* ── SQL kimlik alintilama (deterministik; her zaman cift tirnak) ────────── */ +function quoteIdent(ident: string): string { + // Postgres kimligi: gomulu cift tirnak ikilenir. + return `"${ident.replace(/"/g, '""')}"`; +} diff --git a/apps/server/src/codegen/emitters/nestjs/view.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/view.emitter.spec.ts new file mode 100644 index 0000000..d44a4f3 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/view.emitter.spec.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from "vitest"; +import { emitView } from "./view.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +function viewNode(properties: Record, id: string): StoredNode { + return { + id, + type: "View", + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function tableNode(properties: Record, id: string): StoredNode { + return { + id, + type: "Table", + projectId: "00000000-0000-4000-8000-000000000000", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function ctxFor(...nodes: StoredNode[]): { ctx: EmitterContext } { + const graph = buildCodeGraph(nodes, []); + return { ctx: { graph, target: "nestjs" } }; +} + +const VIEW_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const USERS_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const ORDERS_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + +const ACTIVE_USERS_VIEW = { + ViewName: "ActiveUsersView", + Description: "Active user summary", + Definition: "SELECT id, email FROM users WHERE is_active = true", + SourceTables: ["users"], + Materialized: false, + Columns: [ + { Name: "id", DataType: "INT" }, + { Name: "email", DataType: "VARCHAR" }, + ], +}; + +const USERS_TABLE = { + TableName: "users", + Description: "User", + Columns: [ + { Name: "Id", DataType: "INT", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: true }, + { Name: "IsActive", DataType: "BOOLEAN", IsPrimaryKey: false, IsNotNull: true, IsUnique: false }, + ], +}; + +const ORDERS_TABLE = { + TableName: "orders", + Description: "Order", + Columns: [ + { Name: "Id", DataType: "INT", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: true }, + ], +}; + +describe("emitView (View -> Postgres CREATE VIEW migration SQL)", () => { + it("normal view (Materialized=false) — snapshot", () => { + const node = viewNode(ACTIVE_USERS_VIEW, VIEW_ID); + const { ctx } = ctxFor(node); + const [file] = emitView(ctx.graph.byId(node.id)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "-- Active user summary + + CREATE VIEW "active_users_view" AS + SELECT id, email FROM users WHERE is_active = true; + ", + "language": "sql", + "path": "migrations/001_create_active_users_view.sql", + "surgicalMarkers": 0, + } + `); + }); + + it("TS @ViewEntity de emit eder (repository View'i tip olarak import edebilsin)", () => { + const node = viewNode(ACTIVE_USERS_VIEW, VIEW_ID); + const { ctx } = ctxFor(node); + const files = emitView(ctx.graph.byId(node.id)!, ctx); + expect(files).toHaveLength(2); // migration (sql) + entity (ts) + const entity = files.find((f) => f.language === "typescript")!; + expect(entity.path).toMatch(/\/entities\/.*\.view\.ts$/); + expect(entity.content).toContain('import { ViewColumn, ViewEntity } from "typeorm";'); + expect(entity.content).toContain("@ViewEntity("); + expect(entity.content).toContain("export class ActiveUsersView {"); + expect(entity.content).toContain("@ViewColumn()"); + expect(entity.content).toContain("id!: number;"); // INT → number + expect(entity.content).toContain("email!: string;"); // VARCHAR → string + }); + + it("materialized view + RefreshStrategy yorumu", () => { + const node = viewNode( + { + ViewName: "RevenueDaily", + Description: "Daily revenue", + Definition: "SELECT date_trunc('day', created_at) AS d, sum(total) FROM orders GROUP BY 1", + SourceTables: ["orders"], + Materialized: true, + Columns: [], + RefreshStrategy: "scheduled", + }, + VIEW_ID, + ); + const { ctx } = ctxFor(node); + const [file] = emitView(ctx.graph.byId(node.id)!, ctx); + expect(file.content).toContain("-- RefreshStrategy: scheduled"); + expect(file.content).toContain('CREATE MATERIALIZED VIEW "revenue_daily" AS'); + expect(file.content).toContain("GROUP BY 1;"); + }); + + it("Materialized=false -> RefreshStrategy verilse bile yorum yazilmaz", () => { + const node = viewNode( + { + ...ACTIVE_USERS_VIEW, + RefreshStrategy: "onDemand", + }, + VIEW_ID, + ); + const { ctx } = ctxFor(node); + const [file] = emitView(ctx.graph.byId(node.id)!, ctx); + expect(file.content).not.toContain("RefreshStrategy"); + expect(file.content).toContain('CREATE VIEW "active_users_view" AS'); + }); + + it("Definition'daki sondaki ; ve fazladan bosluk tekrarlanmaz", () => { + const node = viewNode( + { + ViewName: "TrimMe", + Description: "trim", + Definition: " SELECT 1 ; ", + SourceTables: ["users"], + Materialized: false, + Columns: [], + }, + VIEW_ID, + ); + const { ctx } = ctxFor(node); + const [file] = emitView(ctx.graph.byId(node.id)!, ctx); + // Tek ";" — cift ";;" yok, bas/son bosluk trimildi. + expect(file.content).toContain("AS\nSELECT 1;\n"); + expect(file.content).not.toContain(";;"); + }); + + it("CRLF satir sonlari LF'e indirgenir", () => { + const node = viewNode( + { + ViewName: "MultiLine", + Description: "multiline", + Definition: "SELECT a\r\nFROM t\r\nWHERE a > 0", + SourceTables: ["users"], + Materialized: false, + Columns: [], + }, + VIEW_ID, + ); + const { ctx } = ctxFor(node); + const [file] = emitView(ctx.graph.byId(node.id)!, ctx); + expect(file.content).not.toContain("\r"); + expect(file.content).toContain("SELECT a\nFROM t\nWHERE a > 0;"); + }); + + it("migration sirasi: View kaynak Table'larindan AFTER gelir (NNN)", () => { + // users + orders Table'lari + ActiveUsersView (SourceTables: ["users"]). + const view = viewNode(ACTIVE_USERS_VIEW, VIEW_ID); + const users = tableNode(USERS_TABLE, USERS_ID); + const orders = tableNode(ORDERS_TABLE, ORDERS_ID); + const { ctx } = ctxFor(view, users, orders); + const [file] = emitView(ctx.graph.byId(VIEW_ID)!, ctx); + // 2 Table once (001, 002), View sonra (003) — kaynak Table'lardan sonra. + expect(file.path).toBe("migrations/003_create_active_users_view.sql"); + }); + + it("dil 'sql', yol migrations/ altinda, content ends with single newline", () => { + const node = viewNode(ACTIVE_USERS_VIEW, VIEW_ID); + const { ctx } = ctxFor(node); + const [file] = emitView(ctx.graph.byId(node.id)!, ctx); + expect(file.language).toBe("sql"); + expect(file.path).toMatch(/^migrations\/\d{3}_create_active_users_view\.sql$/); + expect(file.content.endsWith("\n")).toBe(true); + expect(file.content.endsWith("\n\n")).toBe(false); + }); + + it("saf SQL -> surgicalMarkers 0", () => { + const node = viewNode(ACTIVE_USERS_VIEW, VIEW_ID); + const { ctx } = ctxFor(node); + const [file] = emitView(ctx.graph.byId(node.id)!, ctx); + expect(file.surgicalMarkers).toBe(0); + }); + + it("throw yok + DETERMINISM: same node twice -> byte-identical", () => { + const node = viewNode(ACTIVE_USERS_VIEW, VIEW_ID); + const { ctx } = ctxFor(node); + expect(() => emitView(ctx.graph.byId(node.id)!, ctx)).not.toThrow(); + const a = emitView(ctx.graph.byId(node.id)!, ctx)[0].content; + const b = emitView(ctx.graph.byId(node.id)!, ctx)[0].content; + expect(a).toBe(b); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/view.emitter.ts b/apps/server/src/codegen/emitters/nestjs/view.emitter.ts new file mode 100644 index 0000000..1dcf91e --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/view.emitter.ts @@ -0,0 +1,120 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import type { CodeNode } from "../../ir"; +import { filePathFor, pascalCase, tsPropName, viewEntityFilePath } from "../../naming"; +import { sqlTypeToTs } from "./sql-type-map"; +import { countSurgicalMarkers } from "../../surgical"; +import type { ViewNode } from "../../../nodes/schemas/view.schema"; + +/* ──────────────────────────────────────────────────────────────────────── + * view.emitter.ts — ViewNode -> Postgres migration SQL (CREATE VIEW). + * + * Contract (aligned with table.emitter / enum.emitter canonical references): + * - no default export; named `export const emitView: NodeEmitter`. + * - PURE function: (node, ctx) -> GeneratedFile[]. No I/O, no throw. + * - Path always via filePathFor(node, ctx.graph) (hardcode FORBIDDEN). NNN + * migration order resolved via graph.migrationIndexOf inside filePathFor: + * View always placed AFTER SourceTables. + * - Content DETERMINISTIC: single input node.properties; no timestamp/random. + * - Content ends with single "\n". + * - surgicalMarkers counted via countSurgicalMarkers(content) (pure SQL -> 0). + * + * A DB View produces SQL migration like Table (no algorithm field): + * CREATE [MATERIALIZED] VIEW AS + * ; + * + * Materialized + RefreshStrategy documented as SQL comment (automatic + * refresh DDL left to user/ops — stay deterministic). + * ──────────────────────────────────────────────────────────────────────── */ + +/** View node properties — ir.ts PropsByKind does not include View (only backend-code + * emitting kinds are listed), so type comes from View schema; no runtime + * conversion (DB is already Zod-validated). */ +type ViewProps = ViewNode["properties"]; + +export const emitView: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const props = node.properties as ViewProps; + // Physical view name from single source: same tableSqlName derivation as + // filePathFor/table.emitter (snake_case; no double pluralization). + const viewName = sqlIdentName(node.name); + const materialized = props.Materialized === true; + + const blocks: string[] = []; + + // ── Header comment (deterministic) ────────────────────────────────────── + if (props.Description) { + blocks.push(`-- ${props.Description}`); + } + + // ── Materialized view refresh strategy -> documentation comment ───────────── + if (materialized && props.RefreshStrategy) { + blocks.push(`-- RefreshStrategy: ${props.RefreshStrategy}`); + } + + // ── CREATE [MATERIALIZED] VIEW body ──────────────────────────────── + const viewKw = materialized ? "MATERIALIZED VIEW" : "VIEW"; + const definition = normalizeDefinition(props.Definition); + blocks.push(`CREATE ${viewKw} ${quoteIdent(viewName)} AS\n${definition};`); + + const body = blocks.join("\n\n") + "\n"; + + const file: GeneratedFile = { + path: filePathFor(node, ctx.graph), + content: body, + language: "sql", + surgicalMarkers: countSurgicalMarkers(body), + }; + + // ── TS @ViewEntity (TypeORM) — importable class when repository returns View type + // (migration alone is not enough; resolveTypeToken resolves this). + // Columns @ViewColumn + camelCase member (tsPropName) + sqlTypeToTs for TS type. ── + const cols = props.Columns ?? []; + const ent: string[] = [`import { ViewColumn, ViewEntity } from "typeorm";`, ""]; + if (props.Description) ent.push(`/** ${props.Description} */`); + ent.push(`@ViewEntity({ name: ${JSON.stringify(viewName)} })`, `export class ${pascalCase(node.name)} {`); + cols.forEach((col, i) => { + ent.push(` @ViewColumn()`, ` ${tsPropName(col.Name)}!: ${sqlTypeToTs(col.DataType, false)};`); + if (i < cols.length - 1) ent.push(""); + }); + ent.push(`}`); + const entityFile: GeneratedFile = { + path: viewEntityFilePath(node, ctx.graph), + content: ent.join("\n") + "\n", + language: "typescript", + surgicalMarkers: 0, + }; + + return [file, entityFile]; +}; + +/* ── Helpers ────────────────────────────────────────────────────────── */ + +/** Normalize View Definition: trim surrounding whitespace, normalize line + * endings to "\n", strip trailing ";" (emitter adds its own ";"). + * Determinism: transform raw string only, order preserved. */ +function normalizeDefinition(raw: string): string { + const trimmed = (raw ?? "").replace(/\r\n?/g, "\n").trim(); + return trimmed.endsWith(";") ? trimmed.slice(0, -1).trimEnd() : trimmed; +} + +/** Physical SQL name: snake_case (same word-split rules as table.emitter tableSqlName). + * Kept here without importing naming.ts — emitter stays dependent only on + * filePathFor (no circular/scope expansion); same boundaries as splitWords. */ +function sqlIdentName(input: string): string { + return splitWords(input).map((w) => w.toLowerCase()).join("_"); +} + +/** Same word splitting as naming.splitWords (camelCase/PascalCase/ + * snake/kebab/space). */ +function splitWords(input: string): string[] { + return (input ?? "") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .split(/[\s\-_./]+/) + .filter((w) => w.length > 0); +} + +/** SQL identifier quoting (same as table.emitter; always double-quote, + * embedded double quotes doubled). */ +function quoteIdent(ident: string): string { + return `"${ident.replace(/"/g, '""')}"`; +} diff --git a/apps/server/src/codegen/emitters/nestjs/worker.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/worker.emitter.spec.ts new file mode 100644 index 0000000..f3bbaee --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/worker.emitter.spec.ts @@ -0,0 +1,270 @@ +import { describe, it, expect } from "vitest"; +import { emitWorker } from "./worker.emitter"; +import { buildCodeGraph } from "../../ir"; +import type { EmitterContext } from "../../types"; +import type { StoredNode } from "../../../nodes/nodes.repository"; +import type { StoredEdge } from "../../../edges/edges.repository"; +import type { EdgeKind } from "../../../edges/schemas/edge.schema"; + +/* ── Fixture helpers ──────────────────────────────────────────────── */ +const PROJECT = "00000000-0000-4000-8000-000000000000"; +const TAB = "22222222-2222-4222-8222-222222222222"; + +function node(type: StoredNode["type"], id: string, properties: Record): StoredNode { + return { + id, + type, + projectId: PROJECT, + positionX: 0, + positionY: 0, + homeTabId: TAB, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function edge(id: string, kind: EdgeKind, sourceNodeId: string, targetNodeId: string): StoredEdge { + return { + id, + projectId: PROJECT, + sourceNodeId, + targetNodeId, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: false }, + }; +} + +function ctxFrom(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { + return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; +} + +/* ── ID'ler ─────────────────────────────────────────────────────────────── */ +const WORKER = "30000000-0000-4000-8000-000000000001"; +const SVC = "30000000-0000-4000-8000-000000000002"; +const SVC2 = "30000000-0000-4000-8000-000000000003"; +const CACHE = "30000000-0000-4000-8000-000000000004"; +const CTRL = "30000000-0000-4000-8000-000000000005"; + +/* ── Node fixtures ──────────────────────────────────────────────────── */ +const thumbnailService = node("Service", SVC, { + ServiceName: "ThumbnailService", + Description: "Thumbnail business logic", + IsTransactionScoped: false, + Dependencies: [], + Methods: [ + { + MethodName: "generate", + Visibility: "public", + Parameters: [], + ReturnType: "void", + IsAsync: true, + Throws: [], + }, + ], +}); + +// Worker'i bir feature'a dusurmek icin onu CALLS eden bir Controller-Service +// zinciri kuruyoruz: ThumbnailController -> ThumbnailService -> (feature "thumbnail"). +const thumbnailController = node("Controller", CTRL, { + ControllerName: "ThumbnailController", + Description: "Thumbnail endpoints", + BasePath: "/thumbnails", + Endpoints: [], +}); + +const thumbnailWorker = node("Worker", WORKER, { + WorkerName: "ThumbnailWorker", + Description: "Periodically cleans old thumbnails", + Schedule: "0 3 * * *", + TaskToExecute: "Delete expired thumbnail records", + TimeoutSeconds: 120, + RetryPolicy: { MaxRetries: 3, BackoffStrategy: "exponential", DelaySeconds: 5 }, + IsEnabled: true, +}); + +describe("emitWorker", () => { + it("tam worker — snapshot (@Cron, DI Service, surgical handler)", () => { + const ctx = ctxFrom( + [thumbnailWorker, thumbnailService, thumbnailController], + [ + edge("e-ctrl-svc", "CALLS", CTRL, SVC), + edge("e-worker-svc", "CALLS", WORKER, SVC), + ], + ); + const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); + expect(file).toMatchInlineSnapshot(` + { + "content": "import { Injectable } from "@nestjs/common"; + import { Cron } from "@nestjs/schedule"; + import { ThumbnailService } from "./thumbnail.service"; + + /** Periodically cleans old thumbnails */ + @Injectable() + export class ThumbnailWorker { + constructor( + private readonly thumbnailService: ThumbnailService, + ) {} + + @Cron("0 3 * * *") + async handleThumbnail(): Promise { + // @solarch:surgical id=30000000-0000-4000-8000-000000000001#handleThumbnail + // Delete expired thumbnail records + // deps: this.thumbnailService + throw new Error("NOT_IMPLEMENTED: ThumbnailWorker.handleThumbnail"); + } + } + ", + "language": "typescript", + "path": "thumbnail/thumbnail.worker.ts", + "surgicalMarkers": 1, + } + `); + }); + + it("dosya yolu ctx.filePathFor ile feature-aware (.worker.ts, rol son-eki tekrarsiz)", () => { + const ctx = ctxFrom( + [thumbnailWorker, thumbnailService, thumbnailController], + [edge("e-ctrl-svc", "CALLS", CTRL, SVC), edge("e-worker-svc", "CALLS", WORKER, SVC)], + ); + const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); + expect(file.path).toBe("thumbnail/thumbnail.worker.ts"); + }); + + it("@nestjs/schedule Cron + @nestjs/common Injectable import edilir", () => { + const ctx = ctxFrom([thumbnailWorker], []); + const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); + expect(file.content).toContain('import { Injectable } from "@nestjs/common";'); + expect(file.content).toContain('import { Cron } from "@nestjs/schedule";'); + }); + + it("@Cron() cron ifadesini kullanir", () => { + const ctx = ctxFrom([thumbnailWorker], []); + const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); + expect(file.content).toContain('@Cron("0 3 * * *")'); + }); + + it("Schedule bossa makul default'a duser (her gece yarisi)", () => { + const w = node("Worker", WORKER, { + WorkerName: "CleanupWorker", + Description: "Temizlik", + Schedule: "", + TaskToExecute: "Temizlik yap", + TimeoutSeconds: 60, + RetryPolicy: { MaxRetries: 0 }, + IsEnabled: true, + }); + const ctx = ctxFrom([w], []); + const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); + expect(file.content).toContain('@Cron("0 0 * * *")'); + }); + + it("CALLS ettigi Service'i DI eder + handler govdesinde erisilebilir (surgical deps)", () => { + const ctx = ctxFrom( + [thumbnailWorker, thumbnailService, thumbnailController], + [edge("e-ctrl-svc", "CALLS", CTRL, SVC), edge("e-worker-svc", "CALLS", WORKER, SVC)], + ); + const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); + expect(file.content).toContain("private readonly thumbnailService: ThumbnailService,"); + expect(file.content).toMatch(/import \{ ThumbnailService \} from ".*thumbnail\.service"/); + expect(file.content).toContain("// deps: this.thumbnailService"); + }); + + it("birden cok Service CALLS -> DEDUP + isme gore sirali DI", () => { + const cleanupService = node("Service", SVC2, { + ServiceName: "AuditService", + Description: "Denetim", + IsTransactionScoped: false, + Dependencies: [], + Methods: [ + { MethodName: "log", Visibility: "public", Parameters: [], ReturnType: "void", IsAsync: true, Throws: [] }, + ], + }); + const ctx = ctxFrom( + [thumbnailWorker, thumbnailService, cleanupService, thumbnailController], + [ + edge("e-ctrl-svc", "CALLS", CTRL, SVC), + edge("e-worker-svc", "CALLS", WORKER, SVC), + edge("e-worker-svc-dup", "CALLS", WORKER, SVC), // duplicate -> tek alan + edge("e-worker-svc2", "CALLS", WORKER, SVC2), + ], + ); + const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); + // DEDUP: thumbnailService tek kez. + const occurrences = file.content.split("private readonly thumbnailService").length - 1; + expect(occurrences).toBe(1); + // Isme gore sirali: auditService (a) thumbnailService'ten (t) FIRST. + const auditIdx = file.content.indexOf("private readonly auditService"); + const thumbIdx = file.content.indexOf("private readonly thumbnailService"); + expect(auditIdx).toBeGreaterThan(-1); + expect(auditIdx).toBeLessThan(thumbIdx); + }); + + it("CALLS hedefi Service degilse DI etmez (yalniz Service enjekte edilir)", () => { + const someCache = node("Cache", CACHE, { CacheName: "ThumbnailCache" }); + const ctx = ctxFrom( + [thumbnailWorker, someCache], + [edge("e-worker-cache", "CALLS", WORKER, CACHE)], + ); + const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); + expect(file.content).not.toContain("constructor("); + expect(file.content).not.toContain("Cache"); + }); + + it("handler icin surgical marker + NOT_IMPLEMENTED govdesi", () => { + const ctx = ctxFrom([thumbnailWorker], []); + const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); + expect(file.surgicalMarkers).toBe(1); + expect(file.content).toContain("// @solarch:surgical id=30000000-0000-4000-8000-000000000001#handleThumbnail"); + expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: ThumbnailWorker.handleThumbnail");'); + }); + + it("DI yoksa constructor uretilmez", () => { + const ctx = ctxFrom([thumbnailWorker], []); + const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); + expect(file.content).not.toContain("constructor("); + // Handler yine de var. + expect(file.content).toContain("async handleThumbnail(): Promise {"); + }); + + it("content ends with single newline", () => { + const ctx = ctxFrom([thumbnailWorker], []); + const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); + expect(file.content.endsWith("}\n")).toBe(true); + expect(file.content.endsWith("}\n\n")).toBe(false); + }); + + it("DETERMINISM: two independent graph builds -> byte-identical", () => { + const nodes = [thumbnailWorker, thumbnailService, thumbnailController]; + const edges = [edge("e-ctrl-svc", "CALLS", CTRL, SVC), edge("e-worker-svc", "CALLS", WORKER, SVC)]; + const ctxA = ctxFrom(nodes, edges); + const a = emitWorker(ctxA.graph.byId(WORKER)!, ctxA)[0].content; + const ctxB = ctxFrom(nodes, edges); + const b = emitWorker(ctxB.graph.byId(WORKER)!, ctxB)[0].content; + expect(a).toBe(b); + }); + + it("edge-case: kayip/bicimsiz property + kopuk CALLS — throw etmez", () => { + const bareWorker = node("Worker", WORKER, { + WorkerName: "BareWorker", + // Description/Schedule/TaskToExecute NONE -> savunmaci okuma bos string. + TimeoutSeconds: 30, + RetryPolicy: { MaxRetries: 0 }, + IsEnabled: true, + }); + // CALLS hedefi graph'ta yok (kopuk edge). + const ctx = ctxFrom([bareWorker], [edge("e-dangling", "CALLS", WORKER, SVC)]); + let file: { content: string; surgicalMarkers: number; path: string } | undefined; + expect(() => { + file = emitWorker(ctx.graph.byId(WORKER)!, ctx)[0]; + }).not.toThrow(); + // Schedule yok -> default; kopuk CALLS -> DI yok. + expect(file!.content).toContain('@Cron("0 0 * * *")'); + expect(file!.content).not.toContain("constructor("); + expect(file!.content).toContain("async handleBare(): Promise {"); + expect(file!.surgicalMarkers).toBe(1); + }); +}); diff --git a/apps/server/src/codegen/emitters/nestjs/worker.emitter.ts b/apps/server/src/codegen/emitters/nestjs/worker.emitter.ts new file mode 100644 index 0000000..68f3bd9 --- /dev/null +++ b/apps/server/src/codegen/emitters/nestjs/worker.emitter.ts @@ -0,0 +1,179 @@ +import type { GeneratedFile, NodeEmitter } from "../../types"; +import { type CodeGraph, type CodeNode } from "../../ir"; +import { + baseNameOf, + camelCase, + filePathFor, + importPathOf, + pascalCase, + relativeImportPath, +} from "../../naming"; +import { ImportCollector } from "../../imports"; +import { countSurgicalMarkers, notImplemented, surgicalMarker } from "../../surgical"; +import type { NodeKind } from "../../../nodes/schemas"; + +/* ──────────────────────────────────────────────────────────────────────── + * worker.emitter.ts — WorkerNode -> /.worker.ts. + * + * Emits an @Injectable() NestJS scheduled worker: + * - ONE handler method decorated with @Cron(). Schedule is a cron + * expression (WorkerNode.Schedule). Falls back to sensible default when + * empty/missing (midnight daily: "0 0 * * *"). + * - DI fields: Services among graph.outEdges(id, "CALLS") targets + * (deterministic: DEDUP + sorted by name). Each injected as + * `private readonly : ` in constructor; + * relative import added for resolvable refs. + * - Handler body = surgicalMarker (Description, TaskToExecute, accessible + * deps this.) + notImplemented(). Cron handler is the "algorithm + * region" — Constructor does not write it; Surgical AI fills it. + * + * NOTE: Worker NOT in PropsByKind (propsOf<...> CANNOT be used). Properties + * read safely (typed helper) via worker.schema.ts shape; missing/malformed + * fields tolerated (NEVER throw). + * + * PURE + DETERMINISTIC: collections sorted, imports via ImportCollector, + * no timestamp/random, content ends with single "\n". + * ──────────────────────────────────────────────────────────────────────── */ + +/** Provider kinds Worker injects via CALLS (full emitters). */ +const INJECTABLE_CALL_KINDS: ReadonlySet = new Set(["Service"]); + +/** Safe (untyped) view of same shape as worker.schema.ts. Worker is not in + * PropsByKind so fields read defensively one by one. */ +interface WorkerPropsView { + Description: string; + Schedule: string; + TaskToExecute: string; +} + +/** One DI-injected service dependency: field name + class type + (optional) import path. */ +interface ResolvedServiceDep { + /** `this.` in constructor */ + field: string; + /** injected class type (pascalCase(name)) */ + className: string; + /** resolved node file path (for import); null when unresolvable. */ + filePath: string | null; +} + +export const emitWorker: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { + const graph = ctx.graph; + const props = readWorkerProps(node); + const className = pascalCase(node.name); + const filePath = filePathFor(node, graph); + + const imports = new ImportCollector(); + imports.add("Injectable", "@nestjs/common"); + imports.add("Cron", "@nestjs/schedule"); + + // ── DI deps: Services among CALLS edge targets ────────── + const deps = collectServiceDeps(node, graph); + for (const dep of deps) { + if (dep.filePath) { + imports.add(dep.className, importPathOf(relativeImportPath(filePath, dep.filePath))); + } + } + + // ── @Cron schedule + handler name ────────────────────────────────────────── + const schedule = resolveSchedule(props.Schedule); + const handlerName = handlerNameOf(node); + + // ── Handler body (surgical) ──────────────────────────────────────────── + const depFields = deps.map((d) => `this.${d.field}`); + const description = buildHandlerDescription(props); + const marker = surgicalMarker({ + nodeId: node.id, + member: handlerName, + description: description.length > 0 ? description : undefined, + deps: depFields.length > 0 ? depFields : undefined, + }); + + // ── Class body ───────────────────────────────────────────────────────── + const lines: string[] = []; + // Emit JSDoc when meaningful (trim >=3 char); skip single-letter/empty noise. + if (props.Description.length >= 3) lines.push(`/** ${props.Description} */`); + lines.push("@Injectable()"); + lines.push(`export class ${className} {`); + + if (deps.length > 0) { + lines.push(" constructor("); + for (const dep of deps) { + lines.push(` private readonly ${dep.field}: ${dep.className},`); + } + lines.push(" ) {}"); + lines.push(""); + } + + lines.push(` @Cron(${JSON.stringify(schedule)})`); + lines.push(` async ${handlerName}(): Promise {`); + for (const ml of marker.split("\n")) lines.push(` ${ml}`); + lines.push(` ${notImplemented(className, handlerName)}`); + lines.push(" }"); + + lines.push("}"); + + const importBlock = imports.render(); + const body = (importBlock ? `${importBlock}\n\n` : "") + lines.join("\n") + "\n"; + + const file: GeneratedFile = { + path: filePath, + content: body, + language: "typescript", + surgicalMarkers: countSurgicalMarkers(body), + }; + return [file]; +}; + +/** Read worker.schema.ts shape safely (missing/malformed -> empty string). */ +function readWorkerProps(node: CodeNode): WorkerPropsView { + const p = node.properties as Record; + return { + Description: typeof p.Description === "string" ? p.Description.trim() : "", + Schedule: typeof p.Schedule === "string" ? p.Schedule.trim() : "", + TaskToExecute: typeof p.TaskToExecute === "string" ? p.TaskToExecute.trim() : "", + }; +} + +/** DEDUP Services among CALLS edge targets, return sorted ResolvedServiceDep list. + * Unresolved endpoints skipped; never throws. */ +function collectServiceDeps(node: CodeNode, graph: CodeGraph): ResolvedServiceDep[] { + // refName (node.name) -> ResolvedServiceDep (DEDUP). + const byKey = new Map(); + for (const e of graph.outEdges(node.id, "CALLS")) { + const tgt = graph.byId(e.targetNodeId); + if (!tgt || !INJECTABLE_CALL_KINDS.has(tgt.kindOf())) continue; + if (byKey.has(tgt.name)) continue; + byKey.set(tgt.name, { + field: camelCase(tgt.name), + className: pascalCase(tgt.name), + filePath: filePathFor(tgt, graph), + }); + } + return [...byKey.values()].sort((a, b) => cmp(a.field, b.field)); +} + +/** @Cron argument: use Schedule cron expression when given, else sensible default + * (midnight daily). Deterministic: property + constant only. */ +function resolveSchedule(schedule: string): string { + return schedule.length > 0 ? schedule : "0 0 * * *"; +} + +/** Cron handler method name: baseNameOf (role suffix "Worker" stripped) -> camelCase + * + idiomatic "handle". "ThumbnailWorker" -> "handleThumbnail". + * Never empty (falls back to "handleTick" when base empty). */ +function handlerNameOf(node: CodeNode): string { + const base = pascalCase(baseNameOf(node)); + return base.length > 0 ? `handle${base}` : "handleTick"; +} + +/** Handler surgical description: TaskToExecute (what it should do) preferred; + * else Description. Line breaks handled by surgicalMarker. */ +function buildHandlerDescription(props: WorkerPropsView): string { + if (props.TaskToExecute.length > 0) return props.TaskToExecute; + return props.Description; +} + +/** Deterministic string compare. */ +function cmp(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} diff --git a/apps/server/src/codegen/import-resolver.service.ts b/apps/server/src/codegen/import-resolver.service.ts new file mode 100644 index 0000000..a08bfa7 --- /dev/null +++ b/apps/server/src/codegen/import-resolver.service.ts @@ -0,0 +1,100 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { spawn } from "node:child_process"; +import { mkdtemp, mkdir, writeFile, readFile, rm, symlink, unlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { ensureFillDepsCache } from "./codegen-fill-deps"; +import type { GeneratedFile } from "./types"; + +/** Resolve the @solarch/cli entry point (subprocess isolation, same as codegen-fill). + * Order: explicit SOLARCH_CLI_ENTRY env → installed @solarch/cli (the default in this + * monorepo / Docker image) → sibling solarch-tools checkout (local tooling dev). */ +function resolveCliEntry(): string { + if (process.env.SOLARCH_CLI_ENTRY) return process.env.SOLARCH_CLI_ENTRY; + try { + return require.resolve("@solarch/cli"); + } catch { + return join(process.cwd(), "..", "solarch-tools", "packages", "cli", "dist", "index.js"); + } +} +const CLI_ENTRY = resolveCliEntry(); + +/* ──────────────────────────────────────────────────────────────────────── + * import-resolver.service.ts — BOUNDARY: AI = ALGORITHM, SYSTEM = IDENTITY + IMPORT. + * + * Surgical AI only writes method BODIES (algorithm) and references types BY NAME; + * it CANNOT add imports (only body is written). Imports are the SYSTEM's deterministic job. + * + * When codegen.generate re-injects saved bodies into fresh skeleton, only BODY is preserved + * so imports drop -> "Cannot find name" (owned entity/DTO/enum/exception + + * typeorm operator). This service writes generated project to temp dir and runs `solarch fix-imports` + * (AI NONE, tsc NONE — pure ts-morph fixMissingImports + owned preference) to wire imports. + * + * Why subprocess (not in-memory): backend's ts-morph/ast-core dependency is NONETUR + * (intentional isolation, same as codegen-fill). Also typeorm operators (ILike) need node_modules + * -> warm deps cache is symlinked. Best effort: if cache missing/error, files + * return UNCHANGED (generation never blocked). + * ──────────────────────────────────────────────────────────────────────── */ +@Injectable() +export class ImportResolverService { + private readonly logger = new Logger(ImportResolverService.name); + + /** Resolve missing imports in filled files (deterministic). Returns files with imports + * wired; on any error returns input UNCHANGED (non-fatal). */ + async resolveImports(files: GeneratedFile[]): Promise { + if (files.length === 0) return files; + const dir = await mkdtemp(join(tmpdir(), "solarch-fiximp-")); + try { + for (const f of files) { + const abs = join(dir, f.path); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, f.content); + } + // typeorm operators (ILike etc.) need node_modules; owned types resolve regardless. + const depsDir = await ensureFillDepsCache(this.logger); + if (depsDir) { + await symlink(join(depsDir, "node_modules"), join(dir, "node_modules"), "dir").catch((e) => + this.logger.warn(`import-resolver symlink failed (owned types still resolve): ${(e as Error).message}`), + ); + } + await new Promise((res) => { + const child = spawn(process.execPath, [CLI_ENTRY, "fix-imports", "--all", "--json"], { cwd: dir, env: process.env }); + let stderr = ""; + child.stderr.on("data", (d) => (stderr += String(d))); + child.on("close", (c) => { + if (c !== 0 && stderr) this.logger.warn(`fix-imports exit ${c}: ${stderr.slice(0, 300)}`); + res(); + }); + child.on("error", (e) => { + this.logger.warn(`fix-imports spawn failed: ${e.message}`); + res(); + }); + }); + // Read back import-fixed files; unchanged ones return as-is. + const resolved = await Promise.all( + files.map(async (f) => { + try { + return { ...f, content: await readFile(join(dir, f.path), "utf8") }; + } catch { + return f; + } + }), + ); + // OBSERVABILITY: silent best-effort hides failures. Log how many files actually + // changed imports -> "thought app was clean but export has Cannot-find-name" + // surprise diagnosable from log (0 -> fix-imports didn't run/failed; N -> ran). + const changed = resolved.filter((f, i) => f.content !== files[i].content).length; + this.logger.log( + `import-resolver: imports resolved in ${changed}/${files.length} files` + + (depsDir ? "" : " (no deps cache → owned types only; cache required for library imports)"), + ); + return resolved; + } catch (e) { + this.logger.warn(`import-resolver failed (best-effort): ${(e as Error).message}`); + return files; + } finally { + await unlink(join(dir, "node_modules")).catch(() => {}); + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } + } +} diff --git a/apps/server/src/codegen/imports.ts b/apps/server/src/codegen/imports.ts new file mode 100644 index 0000000..41452d3 --- /dev/null +++ b/apps/server/src/codegen/imports.ts @@ -0,0 +1,114 @@ +/* ──────────────────────────────────────────────────────────────────────── + * imports.ts — DETERMINISTIC import block generation. + * + * Each emitter builds its ImportCollector, adds symbols, then gets sorted import + * block via render(). Manual "import { X } from ..." FORBIDDEN — consistency and + * determinism come from this class. + * + * Sorting rules (deterministic): + * 1) Modules alphabetical (once), but with "side-type" ordering: + * - 3rd-party / packages (not starting with ./ or ../) FIRST + * - relative ("./", "../") AFTER + * each group alphabetically by module path internally. + * 2) Per module, symbols alphabetical, deduplicated. + * 3) Type-only imports on separate `import type { ... }` line + * (when same module has both value and type, value import carries inline + * `type` qualifier: import { A, type B }). + * ──────────────────────────────────────────────────────────────────────── */ + +interface ModuleImports { + /** value (runtime) symbols */ + values: Set; + /** type-only symbols */ + types: Set; + /** default import symbol (if any) */ + defaultName?: string; + /** `import * as ns from "..."` namespace name (if any) */ + namespace?: string; +} + +export class ImportCollector { + private readonly modules = new Map(); + + private slot(fromModulePath: string): ModuleImports { + let slot = this.modules.get(fromModulePath); + if (!slot) { + slot = { values: new Set(), types: new Set() }; + this.modules.set(fromModulePath, slot); + } + return slot; + } + + /** Add value (runtime) symbol: `import { symbol } from "..."`. */ + add(symbol: string, fromModulePath: string): this { + const slot = this.slot(fromModulePath); + slot.values.add(symbol); + slot.types.delete(symbol); // value import covers type import + return this; + } + + /** Add type-only symbol: `import type { symbol } from "..."`. */ + addType(symbol: string, fromModulePath: string): this { + const slot = this.slot(fromModulePath); + if (!slot.values.has(symbol)) slot.types.add(symbol); + return this; + } + + /** Default import: `import Name from "..."`. */ + addDefault(name: string, fromModulePath: string): this { + this.slot(fromModulePath).defaultName = name; + return this; + } + + /** Namespace import: `import * as ns from "..."`. */ + addNamespace(ns: string, fromModulePath: string): this { + this.slot(fromModulePath).namespace = ns; + return this; + } + + /** Any imports? (render() returns "" when empty.) */ + get isEmpty(): boolean { + return this.modules.size === 0; + } + + /** Sorted import block — does NOT include trailing newline (emitter merges). */ + render(): string { + const isRelative = (p: string) => p.startsWith(".") || p.startsWith("/"); + const paths = [...this.modules.keys()].sort((a, b) => { + const ra = isRelative(a) ? 1 : 0; + const rb = isRelative(b) ? 1 : 0; + if (ra !== rb) return ra - rb; // packages first, relative after + return a < b ? -1 : a > b ? 1 : 0; + }); + + const lines: string[] = []; + for (const path of paths) { + const slot = this.modules.get(path) as ModuleImports; + const sortedValues = [...slot.values].sort(); + const sortedTypes = [...slot.types].sort(); + + // default + namespace lines (separate) + if (slot.defaultName) lines.push(`import ${slot.defaultName} from "${path}";`); + if (slot.namespace) lines.push(`import * as ${slot.namespace} from "${path}";`); + + if (sortedValues.length > 0 && sortedTypes.length > 0) { + // both value and type -> inline `type` qualifier + const parts = [...sortedValues, ...sortedTypes.map((t) => `type ${t}`)].sort(byBareName); + lines.push(`import { ${parts.join(", ")} } from "${path}";`); + } else if (sortedValues.length > 0) { + lines.push(`import { ${sortedValues.join(", ")} } from "${path}";`); + } else if (sortedTypes.length > 0) { + lines.push(`import type { ${sortedTypes.join(", ")} } from "${path}";`); + } + } + return lines.join("\n"); + } +} + +/** Sort mixed "type Foo" and "Foo" list by bare name. */ +function byBareName(a: string, b: string): number { + const bare = (s: string) => s.replace(/^type\s+/, ""); + const ba = bare(a); + const bb = bare(b); + return ba < bb ? -1 : ba > bb ? 1 : 0; +} diff --git a/apps/server/src/codegen/ir.spec.ts b/apps/server/src/codegen/ir.spec.ts new file mode 100644 index 0000000..3f9ed71 --- /dev/null +++ b/apps/server/src/codegen/ir.spec.ts @@ -0,0 +1,548 @@ +import { describe, it, expect } from "vitest"; +import { buildCodeGraph, propsOf } from "./ir"; +import type { StoredNode } from "../nodes/nodes.repository"; +import type { StoredEdge } from "../edges/edges.repository"; +import type { NodeKind } from "../nodes/schemas"; +import type { EdgeKind } from "../edges/schemas/edge.schema"; + +let idSeq = 0; +function uuid(): string { + idSeq++; + return `00000000-0000-4000-8000-${String(idSeq).padStart(12, "0")}`; +} + +function node(type: NodeKind, properties: Record): StoredNode { + return { + id: uuid(), + type, + projectId: "11111111-1111-4111-8111-111111111111", + positionX: 0, + positionY: 0, + homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + version: 1, + properties, + }; +} + +function edge(kind: EdgeKind, source: StoredNode, target: StoredNode): StoredEdge { + return { + id: uuid(), + projectId: "11111111-1111-4111-8111-111111111111", + sourceNodeId: source.id, + targetNodeId: target.id, + kind, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + properties: { IsAsync: false }, + }; +} + +describe("buildCodeGraph — indeksler", () => { + it("byId / byName / allOf / resolveRef cozer", () => { + const svc = node("Service", { ServiceName: "UsersService", Description: "x", IsTransactionScoped: false, Methods: [{ MethodName: "m", ReturnType: "void" }], Dependencies: [] }); + const repo = node("Repository", { RepositoryName: "UsersRepository", Description: "x", EntityReference: "User", CustomQueries: [] }); + const g = buildCodeGraph([svc, repo], []); + + expect(g.byId(svc.id)?.name).toBe("UsersService"); + expect(g.byName("Service", "UsersService")?.id).toBe(svc.id); + expect(g.allOf("Service")).toHaveLength(1); + expect(g.resolveRef("Repository", "UsersRepository")?.id).toBe(repo.id); + expect(g.resolveRef(["Service", "Repository"], "UsersRepository")?.id).toBe(repo.id); + }); + + it("kayip ref -> null (THROW ETMEZ)", () => { + const g = buildCodeGraph([], []); + expect(g.byId("nope")).toBeNull(); + expect(g.byName("Service", "Ghost")).toBeNull(); + expect(g.resolveRef("DTO", "Ghost")).toBeNull(); + expect(g.outEdges("nope")).toEqual([]); + expect(g.inEdges("nope")).toEqual([]); + expect(g.allOf("Table")).toEqual([]); + }); + + it("outEdges / inEdges kind filtresi", () => { + const ctrl = node("Controller", { ControllerName: "UsersController", Description: "x", BaseRoute: "/users", Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: false }] }); + const svc = node("Service", { ServiceName: "UsersService", Description: "x", IsTransactionScoped: false, Methods: [{ MethodName: "m", ReturnType: "void" }], Dependencies: [] }); + const calls = edge("CALLS", ctrl, svc); + const g = buildCodeGraph([ctrl, svc], [calls]); + + expect(g.outEdges(ctrl.id, "CALLS")).toHaveLength(1); + expect(g.outEdges(ctrl.id, "USES")).toHaveLength(0); + expect(g.inEdges(svc.id, "CALLS")[0].id).toBe(calls.id); + }); + + it("propsOf tipli erisim", () => { + const t = node("Table", { TableName: "users", Description: "x", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: false }], ForeignKeys: [], UniqueConstraints: [], CheckConstraints: [], Indexes: [] }); + const g = buildCodeGraph([t], []); + const props = propsOf<"Table">(g.byId(t.id)!); + expect(props.TableName).toBe("users"); + expect(props.Columns[0].Name).toBe("id"); + }); +}); + +describe("moduleOf heuristigi", () => { + it("Service -> ExposedServices iceren Module", () => { + const mod = node("Module", { ModuleName: "UsersModule", Description: "x", StrictBoundaries: false, ExposedServices: ["UsersService"], Dependencies: [] }); + const svc = node("Service", { ServiceName: "UsersService", Description: "x", IsTransactionScoped: false, Methods: [{ MethodName: "m", ReturnType: "void" }], Dependencies: [] }); + const g = buildCodeGraph([mod, svc], []); + expect(g.moduleOf(g.byId(svc.id)!)?.id).toBe(mod.id); + }); + + it("Controller -> CALLS ettigi Service'in modulu", () => { + const mod = node("Module", { ModuleName: "UsersModule", Description: "x", StrictBoundaries: false, ExposedServices: ["UsersService"], Dependencies: [] }); + const svc = node("Service", { ServiceName: "UsersService", Description: "x", IsTransactionScoped: false, Methods: [{ MethodName: "m", ReturnType: "void" }], Dependencies: [] }); + const ctrl = node("Controller", { ControllerName: "UsersController", Description: "x", BaseRoute: "/users", Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: false }] }); + const g = buildCodeGraph([mod, svc, ctrl], [edge("CALLS", ctrl, svc)]); + expect(g.moduleOf(g.byId(ctrl.id)!)?.id).toBe(mod.id); + }); + + it("Module bulunamazsa null", () => { + const svc = node("Service", { ServiceName: "OrphanService", Description: "x", IsTransactionScoped: false, Methods: [{ MethodName: "m", ReturnType: "void" }], Dependencies: [] }); + const g = buildCodeGraph([svc], []); + expect(g.moduleOf(g.byId(svc.id)!)).toBeNull(); + }); + + it("Repository -> acik Service bagi yokken EntityReference'in Model'inin modulune duser", () => { + // Repo'yu Dependencies/CALLS ile baglayan Service NONE; yalniz EntityReference. + const mod = node("Module", { ModuleName: "UsersModule", Description: "x", StrictBoundaries: false, ExposedServices: ["UsersService"], Dependencies: [] }); + const svc = node("Service", { ServiceName: "UsersService", Description: "x", IsTransactionScoped: false, Methods: [{ MethodName: "m", ReturnType: "void" }], Dependencies: [] }); + const model = node("Model", { ClassName: "User", Description: "x", TableRef: "users", Properties: [{ Name: "id", Type: "uuid" }], Methods: [] }); + const repo = node("Repository", { RepositoryName: "UserRepository", Description: "x", EntityReference: "User", CustomQueries: [] }); + // Model'i module baglamak icin Service uzerinden degil; domain-sharing fallback (c) + // ile UsersService'in modulune (UsersModule) dusmeli ("user" stem == "users" stem). + const g = buildCodeGraph([mod, svc, model, repo], []); + expect(g.moduleOf(g.byId(repo.id)!)?.id).toBe(mod.id); + }); + + it("Repository -> hic bag yokken domain-paylasan Service'in modulune duser", () => { + const mod = node("Module", { ModuleName: "OrdersModule", Description: "x", StrictBoundaries: false, ExposedServices: ["OrdersService"], Dependencies: [] }); + const svc = node("Service", { ServiceName: "OrdersService", Description: "x", IsTransactionScoped: false, Methods: [{ MethodName: "m", ReturnType: "void" }], Dependencies: [] }); + // EntityReference cozulmez (Model yok) -> yalniz domain stem fallback kalir. + const repo = node("Repository", { RepositoryName: "OrderRepository", Description: "x", EntityReference: "Order", CustomQueries: [] }); + const g = buildCodeGraph([mod, svc, repo], []); + expect(g.moduleOf(g.byId(repo.id)!)?.id).toBe(mod.id); + }); +}); + +describe("karsilikli feature import'u (circular module) DETERMINISTIC kirilir", () => { + /** auth <-> image karsilikli CALLS: iki feature birbirini import etmek ister. + * Beklenen: kucuk slug (auth) image'i import etmeye DEVAM eder; buyuk slug + * (image) auth'a dogru geri-kenarini DUSURUR -> boot'ta dongu yok. */ + function mutualFixture() { + const authCtrl = node("Controller", { ControllerName: "AuthController", Description: "x", BaseRoute: "auth", Endpoints: [] }); + const authSvc = node("Service", { ServiceName: "AuthService", Description: "x", Dependencies: [], Methods: [] }); + const imageCtrl = node("Controller", { ControllerName: "ImageController", Description: "x", BaseRoute: "image", Endpoints: [] }); + const imageSvc = node("Service", { ServiceName: "ImageService", Description: "x", Dependencies: [], Methods: [] }); + const edges = [ + edge("CALLS", authCtrl, authSvc), + edge("CALLS", imageCtrl, imageSvc), + edge("CALLS", imageSvc, authSvc), // image -> auth + edge("CALLS", authSvc, imageSvc), // auth -> image (karsilikli) + ]; + return buildCodeGraph([authCtrl, authSvc, imageCtrl, imageSvc], edges); + } + + it("geri-kenar forwardRef ile isaretlenir: kenar KORUNUR, lazy emit edilir", () => { + const g = mutualFixture(); + const auth = g.features().find((f) => f.slug === "auth")!; + const image = g.features().find((f) => f.slug === "image")!; + // Iki yon de dependsOn'da KALIR (kenar SILINMEZ → provider import'u kaybolmaz); + // (to, from) en kucuk geri-kenar = image->auth → image.forwardRefDeps=["auth"]. + expect(auth.dependsOn).toContain("image"); + expect(image.dependsOn).toContain("auth"); + expect(image.forwardRefDeps).toContain("auth"); + expect(auth.forwardRefDeps).not.toContain("image"); + }); + + it("export'lar KORUNUR (DI bozulmaz): iki yon de export eder", () => { + const g = mutualFixture(); + const auth = g.features().find((f) => f.slug === "auth")!; + const image = g.features().find((f) => f.slug === "image")!; + // Iki servis de cross-feature inject hedefi -> export edilir (import kirilsa da). + expect(auth.exports.map((e) => e.name)).toContain("AuthService"); + expect(image.exports.map((e) => e.name)).toContain("ImageService"); + }); + + it("uyari uretilir (forwardRef ile kirildi)", () => { + const g = mutualFixture(); + expect(g.warnings()).toHaveLength(1); + const w = g.warnings()[0]; + expect(w).toContain("ImageModule"); + expect(w).toContain("AuthModule"); + expect(w).toContain("forwardRef"); + }); + + it("dongu yoksa uyari yok + dependsOn degismez", () => { + // image -> auth tek yonlu (karsilik yok). + const authCtrl = node("Controller", { ControllerName: "AuthController", Description: "x", BaseRoute: "auth", Endpoints: [] }); + const authSvc = node("Service", { ServiceName: "AuthService", Description: "x", Dependencies: [], Methods: [] }); + const imageCtrl = node("Controller", { ControllerName: "ImageController", Description: "x", BaseRoute: "image", Endpoints: [] }); + const imageSvc = node("Service", { ServiceName: "ImageService", Description: "x", Dependencies: [], Methods: [] }); + const g = buildCodeGraph( + [authCtrl, authSvc, imageCtrl, imageSvc], + [edge("CALLS", authCtrl, authSvc), edge("CALLS", imageCtrl, imageSvc), edge("CALLS", imageSvc, authSvc)], + ); + expect(g.warnings()).toHaveLength(0); + const image = g.features().find((f) => f.slug === "image")!; + expect(image.dependsOn).toEqual(["auth"]); + }); + + it("DETERMINISM: warnings + dependsOn iki kez ayni (cache)", () => { + const g = mutualFixture(); + expect(g.warnings()).toEqual(g.warnings()); + expect(g.features().find((f) => f.slug === "image")!.dependsOn).toEqual( + g.features().find((f) => f.slug === "image")!.dependsOn, + ); + }); +}); + +describe("N-CYCLE (3'lu+) module import'u DETERMINISTIC kirilir (Bug 1 regresyon)", () => { + /** auth -> chat -> messaging -> auth UCLU dongu (cross-feature CALLS). Eski + * breakCircularImports yalniz IKILI ciftleri tariyordu → hicbir cift mutual + * olmadigi icin dongu KACIYORDU ve NestJS boot'ta UndefinedModuleException + * veriyordu. Tarjan SCC ucluyu yakalar; bir geri-kenar forwardRef olur. */ + function threeCycleFixture() { + const authCtrl = node("Controller", { ControllerName: "AuthController", Description: "x", BaseRoute: "auth", Endpoints: [] }); + const authSvc = node("Service", { ServiceName: "AuthService", Description: "x", Dependencies: [], Methods: [] }); + const chatCtrl = node("Controller", { ControllerName: "ChatController", Description: "x", BaseRoute: "chat", Endpoints: [] }); + const chatSvc = node("Service", { ServiceName: "ChatService", Description: "x", Dependencies: [], Methods: [] }); + const msgCtrl = node("Controller", { ControllerName: "MessagingController", Description: "x", BaseRoute: "messaging", Endpoints: [] }); + const msgSvc = node("Service", { ServiceName: "MessagingService", Description: "x", Dependencies: [], Methods: [] }); + const edges = [ + edge("CALLS", authCtrl, authSvc), + edge("CALLS", chatCtrl, chatSvc), + edge("CALLS", msgCtrl, msgSvc), + edge("CALLS", authSvc, chatSvc), // auth -> chat + edge("CALLS", chatSvc, msgSvc), // chat -> messaging + edge("CALLS", msgSvc, authSvc), // messaging -> auth (donguyu kapatir) + ]; + return buildCodeGraph([authCtrl, authSvc, chatCtrl, chatSvc, msgCtrl, msgSvc], edges); + } + + it("uclu dongu YAKALANIR: tam bir geri-kenar forwardRef olur (eskiden 0 uyari)", () => { + const g = threeCycleFixture(); + // (to, from) en kucuk geri-kenar = messaging->auth (to="auth") → forwardRef. + const auth = g.features().find((f) => f.slug === "auth")!; + const chat = g.features().find((f) => f.slug === "chat")!; + const messaging = g.features().find((f) => f.slug === "messaging")!; + expect(messaging.forwardRefDeps).toEqual(["auth"]); + expect(auth.forwardRefDeps).toEqual([]); + expect(chat.forwardRefDeps).toEqual([]); + }); + + it("kenarlar KORUNUR (provider import'u kaybolmaz): dependsOn degismez", () => { + const g = threeCycleFixture(); + expect(g.features().find((f) => f.slug === "auth")!.dependsOn).toEqual(["chat"]); + expect(g.features().find((f) => f.slug === "chat")!.dependsOn).toEqual(["messaging"]); + expect(g.features().find((f) => f.slug === "messaging")!.dependsOn).toEqual(["auth"]); + }); + + it("tek uyari uretilir (forwardRef ile kirildi)", () => { + const g = threeCycleFixture(); + expect(g.warnings()).toHaveLength(1); + const w = g.warnings()[0]; + expect(w).toContain("MessagingModule"); + expect(w).toContain("AuthModule"); + expect(w).toContain("forwardRef"); + }); + + it("kirilan grafik DAG'dir: kalan eager kenarlar arasinda dongu kalmaz", () => { + const g = threeCycleFixture(); + // forwardRef kenari cikarinca: auth->chat, chat->messaging = DAG (messaging->auth lazy). + const features = g.features(); + const eager = new Map(features.map((f) => [f.slug, f.dependsOn.filter((d) => !f.forwardRefDeps.includes(d))])); + expect(eager.get("messaging")).toEqual([]); // tek eager-disi kenar buradaydi + expect(eager.get("auth")).toEqual(["chat"]); + expect(eager.get("chat")).toEqual(["messaging"]); + }); +}); + +describe("#4 cross-feature Repository DI (domain co-location)", () => { + /** order feature'i (OrderController->OrderService) ile payment feature'i + * (PaymentService->PaymentRepository). OrderService CROSS-FEATURE olarak + * PaymentRepository'yi de CALLS eder. Beklenen: PaymentRepository PAYMENT + * feature'inda durur (domain-stem "payment" PaymentService ile co-locate), + * order'a KAYMAZ. Boylece PaymentModule onu provider/export eder ve + * PaymentService bootta cozebilir; OrderModule da PaymentModule'u import eder. */ + function crossFeatureFixture() { + const orderCtrl = node("Controller", { ControllerName: "OrderController", Description: "x", BaseRoute: "orders", Endpoints: [] }); + const orderSvc = node("Service", { ServiceName: "OrderService", Description: "x", Dependencies: [], Methods: [] }); + const orderRepo = node("Repository", { RepositoryName: "OrderRepository", Description: "x", EntityReference: "orders", CustomQueries: [] }); + const paymentSvc = node("Service", { ServiceName: "PaymentService", Description: "x", Dependencies: [], Methods: [] }); + const paymentRepo = node("Repository", { RepositoryName: "PaymentRepository", Description: "x", EntityReference: "payments", CustomQueries: [] }); + const edges = [ + edge("CALLS", orderCtrl, orderSvc), + edge("CALLS", orderSvc, orderRepo), + edge("CALLS", paymentSvc, paymentRepo), + // CROSS-FEATURE: OrderService -> PaymentRepository (+ PaymentService). + edge("CALLS", orderSvc, paymentRepo), + edge("CALLS", orderSvc, paymentSvc), + ]; + const g = buildCodeGraph([orderCtrl, orderSvc, orderRepo, paymentSvc, paymentRepo], edges); + return { g, paymentRepo, orderSvc }; + } + + it("PaymentRepository PAYMENT feature'inda durur (order'a kaymaz)", () => { + const { g, paymentRepo } = crossFeatureFixture(); + // Domain co-location: PaymentRepository, PaymentService ile ayni feature. + expect(g.featureOf(g.byId(paymentRepo.id)!)).toBe("payment"); + }); + + it("PaymentModule PaymentRepository'yi provider + export eder (cross-feature inject hedefi)", () => { + const { g, paymentRepo } = crossFeatureFixture(); + const payment = g.features().find((f) => f.slug === "payment")!; + // Provider: kendi feature'ina ait repository. + expect(payment.repositories.map((r) => r.name)).toContain("PaymentRepository"); + // Export: OrderService (order) onu cross-feature CALLS eder -> export ZORUNLU. + expect(payment.exports.map((e) => e.name)).toContain("PaymentRepository"); + void paymentRepo; + }); + + it("OrderModule payment feature'ini import eder (dependsOn payment)", () => { + const { g } = crossFeatureFixture(); + const order = g.features().find((f) => f.slug === "order")!; + expect(order.dependsOn).toContain("payment"); + // OrderModule PaymentRepository'yi KENDI provider'i olarak tasimaz (payment'in). + expect(order.repositories.map((r) => r.name)).not.toContain("PaymentRepository"); + }); + + it("DETERMINISM: feature atamasi iki kez ayni", () => { + const { g, paymentRepo } = crossFeatureFixture(); + expect(g.featureOf(g.byId(paymentRepo.id)!)).toBe(g.featureOf(g.byId(paymentRepo.id)!)); + }); +}); + +describe("cross-feature Repository PROPERTY-dep (EDGE NONE) wiring (Bug 2 regresyon)", () => { + /** token feature'i (TokenController->TokenService) UserRepository'yi YALNIZ + * Service.Dependencies property'si ile enjekte eder — graf'ta CALLS EDGE NONE. + * Eski derivasyon property-dep'te Service/Repository'yi ATLIYORDU (yalniz edge + * taraniyordu) → TokenModule, UserModule'u import etmiyor + UserModule export + * etmiyordu → boot'ta "Nest can't resolve UserRepository" (UnknownDependencies). + * Beklenen: token.dependsOn ⊇ user (import) VE user.exports ⊇ UserRepository. */ + function propDepFixture() { + const userCtrl = node("Controller", { ControllerName: "UserController", Description: "x", BaseRoute: "users", Endpoints: [] }); + const userSvc = node("Service", { ServiceName: "UserService", Description: "x", Dependencies: [], Methods: [] }); + const userRepo = node("Repository", { RepositoryName: "UserRepository", Description: "x", EntityReference: "users", CustomQueries: [] }); + const tokenCtrl = node("Controller", { ControllerName: "TokenController", Description: "x", BaseRoute: "tokens", Endpoints: [] }); + // TokenService UserRepository'yi PROPERTY ile enjekte eder — CALLS edge NONE. + const tokenSvc = node("Service", { ServiceName: "TokenService", Description: "x", Dependencies: [{ Kind: "Repository", Ref: "UserRepository" }], Methods: [] }); + const edges = [ + edge("CALLS", userCtrl, userSvc), + edge("CALLS", userSvc, userRepo), + edge("CALLS", tokenCtrl, tokenSvc), + // DIKKAT: tokenSvc -> userRepo CALLS edge'i BILEREK NONE (yalniz property-dep). + ]; + return buildCodeGraph([userCtrl, userSvc, userRepo, tokenCtrl, tokenSvc], edges); + } + + it("UserRepository USER feature'inda durur", () => { + const g = propDepFixture(); + const userRepo = g.byName("Repository", "UserRepository")!; + expect(g.featureOf(userRepo)).toBe("user"); + }); + + it("TokenModule UserModule'u import eder (dependsOn ⊇ user) — property-dep'ten", () => { + const g = propDepFixture(); + const token = g.features().find((f) => f.slug === "token")!; + expect(token.dependsOn).toContain("user"); + // TokenModule UserRepository'yi KENDI provider'i olarak TASIMAZ (user'in). + expect(token.repositories.map((r) => r.name)).not.toContain("UserRepository"); + }); + + it("UserModule UserRepository'yi EXPORT eder (cross-feature property-inject hedefi)", () => { + const g = propDepFixture(); + const user = g.features().find((f) => f.slug === "user")!; + expect(user.repositories.map((r) => r.name)).toContain("UserRepository"); + expect(user.exports.map((e) => e.name)).toContain("UserRepository"); + }); +}); + +describe("#7 cross-feature infra provider TEK SAHIP (singleton korunur)", () => { + /** payment (PaymentService) + order (OrderService) IKISI de PaymentGateway + * (ExternalService) enjekte eder; order ayrica payment'i CALLS eder. Eski + * davranis: PaymentGateway hem payment hem order'in infraProviders'ina girer -> + * iki module'de provider -> iki ornek (singleton kirik). Beklenen: PaymentGateway + * TEK feature'da (payment) provider+export; order onu KENDI provider'i olarak + * TASIMAZ, PaymentModule'u import eder (dependsOn=payment); "common"a dusmez. */ + function doubleInjectFixture() { + const payCtrl = node("Controller", { ControllerName: "PaymentController", Description: "x", BaseRoute: "payment", Endpoints: [] }); + const paySvc = node("Service", { ServiceName: "PaymentService", Description: "x", Dependencies: [{ Kind: "ExternalService", Ref: "PaymentGateway" }], Methods: [] }); + const orderCtrl = node("Controller", { ControllerName: "OrderController", Description: "x", BaseRoute: "order", Endpoints: [] }); + const orderSvc = node("Service", { ServiceName: "OrderService", Description: "x", Dependencies: [{ Kind: "ExternalService", Ref: "PaymentGateway" }], Methods: [] }); + const gw = node("ExternalService", { ServiceName: "PaymentGateway", Description: "x", BaseURL: "https://pg.example.com", AuthType: "None", TimeoutSeconds: 10, Endpoints: [] }); + const edges = [ + edge("CALLS", payCtrl, paySvc), + edge("CALLS", orderCtrl, orderSvc), + edge("REQUESTS", paySvc, gw), + edge("REQUESTS", orderSvc, gw), + edge("CALLS", orderSvc, paySvc), // order -> payment (service-call) + ]; + const g = buildCodeGraph([payCtrl, paySvc, orderCtrl, orderSvc, gw], edges); + return { g, gw }; + } + + it("PaymentGateway YALNIZ payment feature'inin provider'i (order'da NONE)", () => { + const { g } = doubleInjectFixture(); + const payment = g.features().find((f) => f.slug === "payment")!; + const order = g.features().find((f) => f.slug === "order")!; + expect(payment.infraProviders.map((n) => n.name)).toContain("PaymentGateway"); + expect(order.infraProviders.map((n) => n.name)).not.toContain("PaymentGateway"); + }); + + it("sahip feature (payment) PaymentGateway'i EXPORT eder; order import eder (dependsOn)", () => { + const { g } = doubleInjectFixture(); + const payment = g.features().find((f) => f.slug === "payment")!; + const order = g.features().find((f) => f.slug === "order")!; + expect(payment.exports.map((e) => e.name)).toContain("PaymentGateway"); + expect(order.exports.map((e) => e.name)).not.toContain("PaymentGateway"); + expect(order.dependsOn).toContain("payment"); + }); + + it("sahip secimi DONGUSUZ: payment secilir (order zaten payment'a bagimli) + uyari yok", () => { + const { g, gw } = doubleInjectFixture(); + // order->payment service-call'u zaten var -> in-degree(payment)=1 > order=0 -> payment sahip. + expect(g.featureOf(g.byId(gw.id)!)).toBe("payment"); + // Sahip secimi yeni geri-kenar (payment->order) yaratmadi -> dongu kirma uyarisi yok. + expect(g.warnings()).toHaveLength(0); + }); + + it("PaymentGateway 'common'a DUSMEZ (CommonModule onu tekrar yazmaz)", () => { + const { g } = doubleInjectFixture(); + const common = g.commonFeature(); + const commonInfra = common?.infraProviders.map((n) => n.name) ?? []; + expect(commonInfra).not.toContain("PaymentGateway"); + }); + + it("DETERMINISM: sahip atamasi iki kez ayni", () => { + const { g, gw } = doubleInjectFixture(); + expect(g.featureOf(g.byId(gw.id)!)).toBe(g.featureOf(g.byId(gw.id)!)); + }); + + it("simetrik enjekte (injectorlar arasi service-call NONE): isimce ilk slug sahip, dongu yok", () => { + // billing + report IKISI de RateCache enjekte eder, aralarinda cagri NONE. + const billCtrl = node("Controller", { ControllerName: "BillingController", Description: "x", BaseRoute: "billing", Endpoints: [] }); + const billSvc = node("Service", { ServiceName: "BillingService", Description: "x", Dependencies: [{ Kind: "Cache", Ref: "RateCache" }], Methods: [] }); + const repCtrl = node("Controller", { ControllerName: "ReportController", Description: "x", BaseRoute: "report", Endpoints: [] }); + const repSvc = node("Service", { ServiceName: "ReportService", Description: "x", Dependencies: [{ Kind: "Cache", Ref: "RateCache" }], Methods: [] }); + const cache = node("Cache", { CacheName: "RateCache", Description: "x", KeyPattern: "r:{id}", TTL_Seconds: 60, Engine: "Redis" }); + const g = buildCodeGraph( + [billCtrl, billSvc, repCtrl, repSvc, cache], + [edge("CALLS", billCtrl, billSvc), edge("CALLS", repCtrl, repSvc), edge("CACHES_IN", billSvc, cache), edge("CACHES_IN", repSvc, cache)], + ); + // Esit in-degree (0/0) -> isimce ilk = billing sahip. + expect(g.featureOf(g.byId(cache.id)!)).toBe("billing"); + const billing = g.features().find((f) => f.slug === "billing")!; + const report = g.features().find((f) => f.slug === "report")!; + expect(billing.infraProviders.map((n) => n.name)).toContain("RateCache"); + expect(report.infraProviders.map((n) => n.name)).not.toContain("RateCache"); + expect(report.dependsOn).toContain("billing"); + expect(g.warnings()).toHaveLength(0); + }); + + it("tek feature enjekte ediyorsa SAHIPLIK KURALI DEVREYE GIRMEZ (eski davranis)", () => { + // Yalniz payment PaymentGateway enjekte eder -> dogrudan payment'in infraProviders'i. + const payCtrl = node("Controller", { ControllerName: "PaymentController", Description: "x", BaseRoute: "payment", Endpoints: [] }); + const paySvc = node("Service", { ServiceName: "PaymentService", Description: "x", Dependencies: [{ Kind: "ExternalService", Ref: "PaymentGateway" }], Methods: [] }); + const gw = node("ExternalService", { ServiceName: "PaymentGateway", Description: "x", BaseURL: "https://pg.example.com", AuthType: "None", TimeoutSeconds: 10, Endpoints: [] }); + const g = buildCodeGraph([payCtrl, paySvc, gw], [edge("CALLS", payCtrl, paySvc), edge("REQUESTS", paySvc, gw)]); + const payment = g.features().find((f) => f.slug === "payment")!; + expect(payment.infraProviders.map((n) => n.name)).toContain("PaymentGateway"); + expect(g.featureOf(g.byId(gw.id)!)).toBe("payment"); + }); + + /** REGRESYON (b4f3 GERCEGI): order, PaymentGateway'i YALNIZ property-Dependency + * ile enjekte eder (REQUESTS EDGE NONE); payment ise edge ile enjekte eder. Eski + * computeInfraOwners SADECE inject-edge'lere bakiyordu -> order injector SAYILMAZ, + * injectorSlugs.size=1 -> tek-sahip kurali atlanir -> PaymentGateway hem order hem + * payment module'une provider olur (singleton kirik). Duzeltme: computeInfraOwners + * artik property-Dependency'leri de sayar -> injector=2 -> tek sahip (payment). */ + it("property-Dependency ile enjekte (EDGE NONE) de tek-sahip kuralini tetikler", () => { + const payCtrl = node("Controller", { ControllerName: "PaymentController", Description: "x", BaseRoute: "payment", Endpoints: [] }); + const paySvc = node("Service", { ServiceName: "PaymentService", Description: "x", Dependencies: [{ Kind: "ExternalService", Ref: "PaymentGateway" }], Methods: [] }); + const orderCtrl = node("Controller", { ControllerName: "OrderController", Description: "x", BaseRoute: "order", Endpoints: [] }); + // order: PaymentGateway SADECE property-dep (REQUESTS edge NONE) + order->payment service-call. + const orderSvc = node("Service", { ServiceName: "OrderService", Description: "x", Dependencies: [{ Kind: "ExternalService", Ref: "PaymentGateway" }], Methods: [] }); + const gw = node("ExternalService", { ServiceName: "PaymentGateway", Description: "x", BaseURL: "https://pg.example.com", AuthType: "None", TimeoutSeconds: 10, Endpoints: [] }); + const g = buildCodeGraph( + [payCtrl, paySvc, orderCtrl, orderSvc, gw], + [ + edge("CALLS", payCtrl, paySvc), + edge("CALLS", orderCtrl, orderSvc), + edge("REQUESTS", paySvc, gw), // YALNIZ payment'in edge'i var + edge("CALLS", orderSvc, paySvc), // order -> payment (service-call) + ], + ); + const payment = g.features().find((f) => f.slug === "payment")!; + const order = g.features().find((f) => f.slug === "order")!; + // PaymentGateway TEK feature'da (payment) provider+export; order onu TASIMAZ. + expect(payment.infraProviders.map((n) => n.name)).toContain("PaymentGateway"); + expect(order.infraProviders.map((n) => n.name)).not.toContain("PaymentGateway"); + expect(payment.exports.map((e) => e.name)).toContain("PaymentGateway"); + expect(order.dependsOn).toContain("payment"); + expect(g.featureOf(g.byId(gw.id)!)).toBe("payment"); + expect(g.warnings()).toHaveLength(0); + }); +}); + +describe("migration sirasi (FK topolojisi + isim)", () => { + it("referans verilen tablo once gelir", () => { + const users = node("Table", { TableName: "users", Description: "x", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: false }], ForeignKeys: [], UniqueConstraints: [], CheckConstraints: [], Indexes: [] }); + const orders = node("Table", { TableName: "orders", Description: "x", Columns: [{ Name: "user_id", DataType: "UUID", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }], ForeignKeys: [{ Columns: ["user_id"], ReferencesTable: "users", ReferencesColumns: ["id"], OnDelete: "CASCADE", OnUpdate: "NO_ACTION" }], UniqueConstraints: [], CheckConstraints: [], Indexes: [] }); + const g = buildCodeGraph([orders, users], []); + const usersIdx = g.migrationIndexOf(g.byName("Table", "users")!); + const ordersIdx = g.migrationIndexOf(g.byName("Table", "orders")!); + expect(usersIdx).toBeLessThan(ordersIdx); + }); + + it("FK dongusu patlatmaz (kalanlar isim sirasinda)", () => { + const a = node("Table", { TableName: "a_tbl", Description: "x", Columns: [{ Name: "b_id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: false }], ForeignKeys: [{ Columns: ["b_id"], ReferencesTable: "b_tbl", ReferencesColumns: ["id"], OnDelete: "NO_ACTION", OnUpdate: "NO_ACTION" }], UniqueConstraints: [], CheckConstraints: [], Indexes: [] }); + const b = node("Table", { TableName: "b_tbl", Description: "x", Columns: [{ Name: "a_id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: false }], ForeignKeys: [{ Columns: ["a_id"], ReferencesTable: "a_tbl", ReferencesColumns: ["id"], OnDelete: "NO_ACTION", OnUpdate: "NO_ACTION" }], UniqueConstraints: [], CheckConstraints: [], Indexes: [] }); + const g = buildCodeGraph([a, b], []); + expect(() => g.migrationIndexOf(g.byName("Table", "a_tbl")!)).not.toThrow(); + }); +}); + +describe("#4 orphan join tablosu entity'si forFeature'a kaydedilir (boot regresyonu)", () => { + /** Gercek e-ticaret deseni: products tablosunu bir Repository gosterir (sentetik + * entity CEKIRDEK), order_items ise hicbir repo gostermez ama products'a FK + * verir (orphan join tablosu). entity-synthesis order_items icin @Entity + + * @ManyToOne(Product) uretir; Product entity'sinde @OneToMany(OrderItem) dogar. + * Eger order_items HICBIR feature'in TypeOrmModule.forFeature'ina girmezse + * TypeORM bootta "Entity metadata for Product#orderItems not found" firlatir. + * Bu test, IR'in order_items'i bir feature'a (FK co-location -> products'in + * feature'i) atadigini ve o feature'in syntheticEntityTables'inda durdugunu + * dogrular -> module.emitter onu forFeature'a kaydeder -> uygulama BOOT BOOTS. */ + function joinFixture() { + const products = node("Table", { TableName: "products", Description: "x", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }], ForeignKeys: [], UniqueConstraints: [], CheckConstraints: [], Indexes: [] }); + const productSvc = node("Service", { ServiceName: "ProductService", Description: "x", Dependencies: [], Methods: [] }); + const productRepo = node("Repository", { RepositoryName: "ProductRepository", Description: "x", EntityReference: "products", CustomQueries: [] }); + const orderItems = node("Table", { TableName: "order_items", Description: "join", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }, { Name: "product_id", DataType: "UUID", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }], ForeignKeys: [{ Columns: ["product_id"], ReferencesTable: "products", ReferencesColumns: ["id"], OnDelete: "CASCADE", OnUpdate: "NO_ACTION" }], UniqueConstraints: [], CheckConstraints: [], Indexes: [] }); + const g = buildCodeGraph([products, productSvc, productRepo, orderItems], [edge("CALLS", productSvc, productRepo), edge("WRITES", productRepo, products)]); + return { g, orderItems, products }; + } + + it("order_items bir feature'a atanir (kendi orphan slug'inda ASILI KALMAZ)", () => { + const { g, orderItems } = joinFixture(); + const slug = g.featureOf(g.byId(orderItems.id)!); + // FK co-location: products'in feature'i (product). Orphan "order-items"e DUSMEZ. + expect(slug).toBe("product"); + }); + + it("atandigi feature'in syntheticEntityTables'inda durur (forFeature kaydi)", () => { + const { g, orderItems, products } = joinFixture(); + const product = g.features().find((f) => f.slug === "product")!; + const synthNames = product.syntheticEntityTables.map((t) => t.name); + // Hem cekirdek (products) hem FK-kapanis (order_items) AYNI feature'da -> ikisi de forFeature'a. + expect(synthNames).toContain("products"); + expect(synthNames).toContain("order_items"); + void orderItems; + void products; + }); + + it("DETERMINISM: atama iki kez ayni", () => { + const { g, orderItems } = joinFixture(); + expect(g.featureOf(g.byId(orderItems.id)!)).toBe(g.featureOf(g.byId(orderItems.id)!)); + }); +}); diff --git a/apps/server/src/codegen/ir.ts b/apps/server/src/codegen/ir.ts new file mode 100644 index 0000000..9fd3b75 Binary files /dev/null and b/apps/server/src/codegen/ir.ts differ diff --git a/apps/server/src/codegen/naming.spec.ts b/apps/server/src/codegen/naming.spec.ts new file mode 100644 index 0000000..6d4f51b --- /dev/null +++ b/apps/server/src/codegen/naming.spec.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from "vitest"; +import { + pascalCase, + camelCase, + kebabCase, + snakeCase, + pluralizeSnake, + tableSqlName, + scalarTsType, + splitWords, + relativeImportPath, + importPathOf, + resolveTypeRef, +} from "./naming"; +import { buildCodeGraph } from "./ir"; +import { ImportCollector } from "./imports"; + +describe("case donusumleri", () => { + it("splitWords karisik girdileri boler", () => { + expect(splitWords("userId")).toEqual(["user", "Id"]); + expect(splitWords("UserProfile")).toEqual(["User", "Profile"]); + expect(splitWords("user_profile")).toEqual(["user", "profile"]); + expect(splitWords("user-profile")).toEqual(["user", "profile"]); + expect(splitWords("HTTPServer")).toEqual(["HTTP", "Server"]); + }); + + it("pascalCase", () => { + expect(pascalCase("user_profile")).toBe("UserProfile"); + expect(pascalCase("order-status")).toBe("OrderStatus"); + expect(pascalCase("UsersService")).toBe("UsersService"); + }); + + it("camelCase", () => { + expect(camelCase("UserProfile")).toBe("userProfile"); + expect(camelCase("order_status")).toBe("orderStatus"); + }); + + it("kebabCase", () => { + expect(kebabCase("UserProfile")).toBe("user-profile"); + expect(kebabCase("OrderStatus")).toBe("order-status"); + expect(kebabCase("HTTPServer")).toBe("http-server"); + }); + + it("snakeCase", () => { + expect(snakeCase("UserProfile")).toBe("user_profile"); + expect(snakeCase("orderStatus")).toBe("order_status"); + }); +}); + +describe("pluralizeSnake", () => { + it("temel kurallar", () => { + expect(pluralizeSnake("User")).toBe("users"); + expect(pluralizeSnake("Category")).toBe("categories"); + expect(pluralizeSnake("Box")).toBe("boxes"); + expect(pluralizeSnake("OrderItem")).toBe("order_items"); + expect(pluralizeSnake("Address")).toBe("addresses"); + }); + + it("unluden sonra -y -> -ys", () => { + expect(pluralizeSnake("Day")).toBe("days"); + }); +}); + +describe("tableSqlName (fiziksel tablo adi — cogullamaz)", () => { + it("acik TableName'i LITERAL kabul eder (tekrar cogullamaz)", () => { + // Eski hata: pluralizeSnake("users")="userses". tableSqlName bunu yapmaz. + expect(tableSqlName("users")).toBe("users"); + expect(tableSqlName("orders")).toBe("orders"); + expect(tableSqlName("categories")).toBe("categories"); + }); + it("yalniz snake_case'ler (tekil/PascalCase oldugu gibi)", () => { + expect(tableSqlName("User")).toBe("user"); + expect(tableSqlName("OrderItem")).toBe("order_item"); + }); +}); + +describe("scalarTsType (sema tipi -> gecerli TS skaleri)", () => { + it("yaygin tipleri normalize eder", () => { + expect(scalarTsType("uuid")).toBe("string"); + expect(scalarTsType("text")).toBe("string"); + expect(scalarTsType("int")).toBe("number"); + expect(scalarTsType("long")).toBe("number"); + expect(scalarTsType("decimal")).toBe("number"); + expect(scalarTsType("bool")).toBe("boolean"); + expect(scalarTsType("datetime")).toBe("Date"); + expect(scalarTsType("")).toBe("string"); + }); + it("bilinmeyen tipi oldugu gibi birakir (ozel sinif/DTO adi)", () => { + expect(scalarTsType("UserDto")).toBe("UserDto"); + }); + it("generic SQL ENUM/JSON tiplerini GECERLI TS'e cevirir (bare ENUM/JSON uretmez)", () => { + // EnumRef'siz generic SQL ENUM (or. repository CustomQuery param Type="ENUM") + // -> string. Eskiden bare `ENUM` -> TS2304 (derleme kirik). sql-type-map ile tutarli. + expect(scalarTsType("ENUM")).toBe("string"); + expect(scalarTsType("enum")).toBe("string"); + expect(scalarTsType("JSON")).toBe("Record"); + expect(scalarTsType("jsonb")).toBe("Record"); + // Ek SQL skaler varyantlari (sql-type-map ile tutarli). + expect(scalarTsType("bigint")).toBe("number"); + expect(scalarTsType("smallint")).toBe("number"); + expect(scalarTsType("char")).toBe("string"); + expect(scalarTsType("timestamptz")).toBe("Date"); + expect(scalarTsType("time")).toBe("Date"); + }); +}); + +describe("import yollari", () => { + it("importPathOf uzantiyi atar", () => { + expect(importPathOf("users/users.service.ts")).toBe("users/users.service"); + }); + + it("relativeImportPath ayni klasor", () => { + expect(relativeImportPath("users/users.controller.ts", "users/users.service.ts")).toBe( + "./users.service", + ); + }); + + it("relativeImportPath kardes klasor", () => { + expect(relativeImportPath("users/users.service.ts", "common/enums/role.enum.ts")).toBe( + "../common/enums/role.enum", + ); + }); + + it("relativeImportPath alt klasor", () => { + expect(relativeImportPath("users/users.service.ts", "users/dto/create-user.dto.ts")).toBe( + "./dto/create-user.dto", + ); + }); +}); + +describe("resolveTypeRef — cozulemeyen serbest tip GUVENLI degrade olur (TS2304 onle)", () => { + // Bos graf: hicbir node yok -> her PascalCase tip adi COZULEMEZ. Eskiden token + // oldugu gibi gecip `Promise` (TS2304) uretiyordu. Artik acik-uclu + // `Record`'a degrade olur: hem donus (obje insasi) hem tuketim + // (member access -> unknown) derlenir; contract-lint ayrica uyari verir. + const g = buildCodeGraph([], []); + const ref = (raw: string) => resolveTypeRef(raw, g, "src/x.ts", new ImportCollector()); + + it("ciplak cozulemeyen tip -> Record", () => { + expect(ref("TokenPair")).toBe("Record"); + expect(ref("PaymentResult")).toBe("Record"); + }); + + it("sarmalayici korunur: Promise, X[]", () => { + expect(ref("Promise")).toBe("Promise>"); + expect(ref("Cart[]")).toBe("Record[]"); + }); + + it("skaler ve TS keyword'leri ETKILENMEZ", () => { + expect(ref("UUID")).toBe("string"); + expect(ref("Promise")).toBe("Promise"); + expect(ref("number")).toBe("number"); + expect(ref("void")).toBe("void"); + }); +}); diff --git a/apps/server/src/codegen/naming.ts b/apps/server/src/codegen/naming.ts new file mode 100644 index 0000000..1828997 --- /dev/null +++ b/apps/server/src/codegen/naming.ts @@ -0,0 +1,535 @@ +import type { NodeKind } from "../nodes/schemas"; +import type { CodeNode, CodeGraph } from "./ir"; +import type { ImportCollector } from "./imports"; + +/* ──────────────────────────────────────────────────────────────────────── + * naming.ts — DETERMINISTIC isim ve yol uretimi. + * + * Tum emitter'lar isim donusumu ve dosya yolu icin YALNIZ bu modulu kullanir + * (hardcode case donusumu YASAK). Ayni node -> ayni isim -> ayni yol. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Bir tanimlayiciyi kelimelere boler: camelCase, PascalCase, snake_case, + * kebab-case, "bosluklu metin" — hepsini normalize eder. */ +export function splitWords(input: string): string[] { + return ( + input + // camelCase / PascalCase siniri: "userId" -> "user Id" + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + // ardisik buyuk harf + sozcuk: "HTTPServer" -> "HTTP Server" + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + // ayraclar + .split(/[\s\-_./]+/) + .filter((w) => w.length > 0) + ); +} + +const lower = (w: string) => w.toLowerCase(); +const cap = (w: string) => (w.length === 0 ? w : w[0].toUpperCase() + w.slice(1).toLowerCase()); + +/** "user_profiles" / "UserProfile" -> "UserProfile". */ +export function pascalCase(input: string): string { + return splitWords(input).map(cap).join(""); +} + +/** "UserProfile" -> "userProfile". */ +export function camelCase(input: string): string { + const p = pascalCase(input); + return p.length === 0 ? p : p[0].toLowerCase() + p.slice(1); +} + +/** Bir property/field adini TS UYE TANIMLAYICISI olarak normalize eder (entity/model + * alani + iliski prop). Diyagram PascalCase yazsa da (Id, CustomerId, Title) idiomatik + * TS camelCase'e cevirir → Surgical AI grounding'i de bu yuzeyi okuyup uyumlu camelCase + * govde uretir. DB kolon ADI NOT — o ham `.Name`'den snakeCase ile ayri turetilir + * (SnakeNamingStrategy member adini ayni snake_case'e indirir, drift olmaz). */ +export const tsPropName = camelCase; + +/** "UserProfile" -> "user-profile". */ +export function kebabCase(input: string): string { + return splitWords(input).map(lower).join("-"); +} + +/** "UserProfile" -> "user_profile". */ +export function snakeCase(input: string): string { + return splitWords(input).map(lower).join("_"); +} + +/* ── Fiziksel tablo adi — TEK SOURCE ─────────────────────────────────────── + * Bir Table node'unun TableName'i (ve TableRef ile ona baglanan Model'in + * @Entity adi) DAIMA bu fonksiyondan gelir; boylece migration'daki + * `CREATE TABLE` ile entity'nin `@Entity(...)` adi ASLA ayrismaz. + * + * KARAR: TableName author'in sectigi LITERAL fiziksel tablo adidir — tekrar + * cogullanmaz (yalniz snake_case'lenir). "users" -> "users", "User" -> "user", + * "OrderItem" -> "order_item". (Acik ad yoksa class adindan turetmek icin ayrica + * pluralizeSnake kullanilir — bkz. model.emitter resolveTableName.) */ +export function tableSqlName(rawTableName: string): string { + return snakeCase(rawTableName); +} + +/* ── Cok basit, deterministik Ingilizce cogullama ────────────────────────── + * Kapsam: yaygin kurallar. AI/sozluk NONE — codegen deterministik kalsin. + * "category" -> "categories", "box" -> "boxes", "user" -> "users". + * YALNIZ acik TableName'i WITHOUT bir tabloyu class adindan turetmek icin + * kullanilir (Model.TableRef yoksa). Acik TableName'e ASLA uygulanmaz. */ +export function pluralizeSnake(input: string): string { + const base = snakeCase(input); + if (base.length === 0) return base; + const lastSeg = base.split("_").pop() as string; + const prefix = base.slice(0, base.length - lastSeg.length); + return prefix + pluralizeWord(lastSeg); +} + +function pluralizeWord(w: string): string { + if (w.length === 0) return w; + const endsWith = (s: string) => w.endsWith(s); + // -y -> -ies (unsuzden sonra) + if (endsWith("y") && w.length > 1 && !"aeiou".includes(w[w.length - 2])) { + return w.slice(0, -1) + "ies"; + } + // -s, -ss, -sh, -ch, -x, -z -> -es + if (endsWith("s") || endsWith("sh") || endsWith("ch") || endsWith("x") || endsWith("z")) { + return w + "es"; + } + return w + "s"; +} + +/* ── Sema tip stringi -> TypeScript skaler tipi ──────────────────────────── + * Semadaki serbest tip string'leri (Param/QueryParam.Type, Model.Property.Type, + * DTO.Field.DataType) yaygin es anlamlilari TS tiplerine normalize eder. Bilinmeyen + * tip OLDUGU GIBI doner (ozel sinif/DTO adi gecisi icin). Uc emitter da (controller/ + * model/dto) ayni eslemeyi paylassin diye buradadir — aksi halde controller + * `id: uuid` gibi GECERSIZ TS uretirdi (uuid bir TS tipi degil). */ +export function scalarTsType(raw: string): string { + switch ((raw ?? "").trim().toLowerCase()) { + case "": + return "string"; + case "string": + case "text": + case "varchar": + case "char": + case "bpchar": + case "citext": + case "guid": + case "uuid": + case "enum": + // Generic SQL ENUM given as a free-form type string (no EnumRef, no enum node to + // resolve) → string. EnumRef-backed columns resolve to the real generated enum type + // via a separate path (sql-type-map.columnTsType); here an unresolvable generic + // "ENUM" must not emit invalid TS (a bare `ENUM`) → string. (Consistent with + // sql-type-map.ts sqlScalarTsType("ENUM") === "string".) + return "string"; + case "int": + case "integer": + case "bigint": + case "smallint": + case "tinyint": + case "long": + case "number": + case "float": + case "double": + case "real": + case "decimal": + case "numeric": + return "number"; + case "bool": + case "boolean": + return "boolean"; + case "date": + case "datetime": + case "timestamp": + case "timestamptz": + case "time": + return "Date"; + // JSON/JSONB: serbest tip string'i olarak verilen JSON -> Record + // (sql-type-map ile tutarli; bare `JSON` GECERSIZ TS uretmesin). + case "json": + case "jsonb": + return "Record"; + // Parametresiz koleksiyon-ism'i (DataType="Array"/"List", eleman tipi yok) → bare + // `Array` GECERSIZ TS (TS2314: tip argumani ister). Guvenli degradasyon: `unknown` + // (IsCollection ekiyle `unknown[]`). Parametreli `List`/`X[]` resolveTypeRef yolunda. + case "array": + case "list": + return "unknown"; + default: + return raw; + } +} + +/* ── Serbest tip stringi -> GECERLI TS tipi (scalar + ref cozumu + import) ── + * Repository CustomQuery ve Service metot param/return tipleri semada SERBEST + * string'tir (or. "User", "UUID", "User[]", "Promise"). Ham birakilirsa + * "User"/"UUID" gibi tanimsiz semboller `nest build`'i TS2304 ile kirar. + * + * resolveTypeRef tek bir tip token'ini: + * 1) scalarTsType ile normalize eder (uuid->string, int->number, date->Date...), + * 2) skaler degilse Model/DTO/Enum node'u olarak cozmeye calisir -> cozulurse + * sinif adini import eder (fromFile'a goreli) ve dondurur, + * 3) hicbiri degilse token'i OLDUGU GIBI birakir (serbest tip; derlemeyi + * kirabilir ama bu zaten kullanicinin verdigi tiptir — controller.emitter + * ile ayni tolerans). + * + * Kompozit tipleri (Array, X[], Promise, X | null, X | undefined) parcalar: + * sarmalayiciyi korur, ICERDEKI tanimlayicilari tek tek cozer. Determinizm: + * yalniz ham string uzerinde regex; node sirasi graph'tan gelir. + * ──────────────────────────────────────────────────────────────────────── */ +export function resolveTypeRef( + rawType: string, + graph: CodeGraph, + fromFile: string, + imports: ImportCollector, +): string { + const raw = (rawType ?? "").trim(); + if (raw.length === 0) return "void"; + // LLM-yazimi koleksiyon-ism'i: `List` (Java/Kotlin) gecerli TS degil → + // TS-native `Array` (sozcuk-sinirli, buyuk/kucuk harf duyarsiz; `UserList` + // dokunulmaz). Asagidaki dongu `Array`'i zaten passthrough gecer, icteki X'i cozer. + const t = raw.replace(/\bList\s*[]|, bosluk) + // oldugu gibi koru. Bu, Promise, User[], User | null gibi tipleri kapsar. + return t.replace(/[A-Za-z_][A-Za-z0-9_]*/g, (token) => resolveTypeToken(token, graph, fromFile, imports)); +} + +/** Bilinen tip-anahtar kelimeleri (cozulmez; oldugu gibi gecer). */ +const TS_TYPE_KEYWORDS = new Set([ + "Promise", "Array", "Record", "Map", "Set", "Partial", "Readonly", "Pick", "Omit", + "string", "number", "boolean", "Date", "void", "any", "unknown", "null", "undefined", + "object", "never", "bigint", "symbol", "true", "false", +]); + +function resolveTypeToken( + token: string, + graph: CodeGraph, + fromFile: string, + imports: ImportCollector, +): string { + if (TS_TYPE_KEYWORDS.has(token)) return token; + // Skaler es anlamli mi? (uuid/int/date ...) -> TS skaleri. + const scalar = scalarTsType(token); + if (scalar !== token) return scalar; + // Skaler degil -> bir Model/DTO/Enum node'u olabilir; coz + import et. + const node = graph.resolveRef(["Model", "DTO", "Enum"], token); + if (node) { + const cls = pascalCase(node.name); + const path = importPathOf(relativeImportPath(fromFile, filePathFor(node, graph))); + // Enum tip-pozisyonunda gorunse de govdede DEGER olarak kullanilir (Status.SUBMITTED, + // Object.values(Enum)) → VALUE import. `import type` olsa TS1361 verir. Model/DTO tip-only kalir. + if (node.kindOf() === "Enum") imports.add(cls, path); + else imports.addType(cls, path); + return cls; + } + // Bir DB View mi? (repository View dondurur). View migration + TS @ViewEntity uretir; + // tip olarak @ViewEntity sinifini import et (filePathFor(View) migration'dir → viewEntityFilePath). + const view = graph.resolveRef("View", token); + if (view) { + const cls = pascalCase(view.name); + // @ViewEntity bir SINIF — govdede deger olarak da kullanilabilir (repository token, + // new) → VALUE import (Enum ile ayni; `import type` TS1361 verir). + imports.add(cls, importPathOf(relativeImportPath(fromFile, viewEntityFilePath(view, graph)))); + return cls; + } + // Bir Table'dan SENTEZLENEN entity adi mi? (Model yokken servis/repo "User" + // dondurur; sentetik entity sinif adi entityClassNameForTable ile eslesir.) + // Hem ham tablo adi ("Users") hem sentetik sinif adi ("User") eslenir. + const synthTable = resolveSyntheticEntityType(token, graph); + if (synthTable) { + const cls = entityClassNameForTable(synthTable); + imports.addType(cls, importPathOf(relativeImportPath(fromFile, synthEntityFilePath(synthTable, graph)))); + return cls; + } + // Cozulemeyen serbest ad (hicbir Model/DTO/Enum/View/sentetik-Table node'una + // cozulmedi): bu, graf'in bir KONTRAT BOSLUGUDUR — referans edilen tipin tanimi + // yok. Token'i OLDUGU GIBI birakmak `Promise` gibi TS2304 ile derlemeyi + // KIRARDI. Bunun yerine acik-uclu `Record`'a GUVENLI degrade et: + // · donus pozisyonu: `{ accessToken, ... }` obje literali atanabilir, + // · tuketim: `result.accessToken` -> unknown (index signature) — ikisi de derlenir. + // Bosluk yine de YUKSEK SESLE bildirilir (contract-lint unresolvedTypeRefs uyarisi). + return "Record"; +} + +/** Bir tip token'i (or. "User" veya "Users") bir Table'dan SENTEZLENECEK + * entity'ye karsilik geliyor mu? Yalniz (a) bir Repository tarafindan referans + * edilen ve (b) Model'i WITHOUT Table'lar aday — bunlar icin sentetik entity + * dosyasi gercekten uretilir (aksi halde import TS2307 verirdi). Token, tablonun + * ham adi VEYA sentetik sinif adi (singular-pascal) olabilir. */ +function resolveSyntheticEntityType(token: string, graph: CodeGraph): CodeNode | null { + const want = pascalCase(token); + for (const table of graph.allOf("Table")) { + if (hasBackingModel(table, graph)) continue; + if (!isRepositoryReferenced(table, graph)) continue; + if (pascalCase(table.name) === want || entityClassNameForTable(table) === want) { + return table; + } + } + return null; +} + +/** Bir Table, bir Repository.EntityReference ile referans ediliyor mu? */ +function isRepositoryReferenced(table: CodeNode, graph: CodeGraph): boolean { + for (const repo of graph.allOf("Repository")) { + const ref = (repo.properties as Record).EntityReference; + if (typeof ref !== "string" || ref.length === 0) continue; + const node = graph.resolveRef(["Model", "Table"], ref); + if (node && node.id === table.id) return true; + } + return false; +} + +/** Bu Table'i TableRef ile temsil eden bir Model var mi? (varsa Model entity'si + * uretilir; sentez gereksiz — resolveTypeRef Model'i ayri cozer.) */ +function hasBackingModel(table: CodeNode, graph: CodeGraph): boolean { + for (const m of graph.allOf("Model")) { + const tableRef = (m.properties as Record).TableRef; + if (typeof tableRef === "string" && graph.resolveRef("Table", tableRef)?.id === table.id) { + return true; + } + } + return false; +} + +/* ──────────────────────────────────────────────────────────────────────── + * Feature klasoru + dosya yolu — ARCHITECTURE-FARKINDA. + * + * Her node bir FEATURE slug'a ("auth", "image", ...) veya "common"a aittir; + * bu atama ir.ts feature-inference tarafindan yapilir ve graph.featureOf(node) + * ile okunur. Dosya yolunun KLASORU feature'dir; DOSYA ADI ise rol son-ekini + * TEKRARLAMAYAN idiomatik isimden (baseNameOf -> kebab) turetilir. + * + * AuthController -> src/auth/auth.controller.ts + * UserRepository -> src/user/user.repository.ts (feature'ina gore) + * AuthResponseDTO -> src/auth/dto/auth-response.dto.ts + * ImageGenerationSvc -> src/image/image-generation.service.ts + * + * Table migrations/ altindadir — feature'a bagli degildir (degismez). + * ──────────────────────────────────────────────────────────────────────── */ + +/** Bir kind icin idiomatik rol son-eki (dosya adinda TEKRARLANMAZ). NestJS + * dosya adi zaten ".controller.ts"/".service.ts" eki tasidigindan sinif + * adindaki "Controller"/"Service"/... son-eki dosya kok adindan dusurulur. */ +const ROLE_SUFFIX_BY_KIND: Partial> = { + Controller: ["Controller"], + Service: ["Service"], + Repository: ["Repository"], + Module: ["Module"], + Exception: ["Exception", "Error"], + // DTO adlari "...DTO"/"...Dto" eki tasir -> dosya adi bunu tekrarlamaz. + DTO: ["DTO", "Dto"], + // ── Mimari altyapi kind'lari (rol eki dosya adinda TEKRARLANMAZ) ────────── + // "ImageResultCache" -> base "ImageResult" -> image-result.cache.ts. + Cache: ["Cache"], + // "ImageJobsQueue"/"ImageMessageQueue" -> "ImageJobs"/"Image" -> *.queue.ts. + MessageQueue: ["MessageQueue", "Queue"], + // "ThumbnailWorker" -> "Thumbnail" -> thumbnail.worker.ts. + Worker: ["Worker"], + // "OrderCreatedEventHandler"/"OrderCreatedHandler" -> "OrderCreated" -> *.handler.ts. + EventHandler: ["EventHandler", "Handler"], + // "CheckoutOrchestrator" -> "Checkout" -> checkout.orchestrator.ts. + Orchestrator: ["Orchestrator"], + // "StableDiffusionApi"/"StripeClient"/"MailService" -> "StableDiffusion"/ + // "Stripe"/"Mail" -> *.client.ts (idiomatik dis servis istemcisi). + ExternalService: ["Client", "Api", "Service"], + // "AuthMiddleware" -> "Auth" -> auth.middleware.ts. + Middleware: ["Middleware"], + // "PublicApiGateway"/"PublicGateway" -> "PublicApi"/"Public" -> *.gateway.ts. + APIGateway: ["APIGateway", "Gateway"], +}; + +/** Bir node'un IDIOMATIK temel adi — rol son-eki ayiklanmis (dosya/feature adi + * turetmek icin). "AuthController"->"Auth", "UserRepository"->"User", + * "AuthResponseDTO"->"AuthResponse", "ImageGenerationService"->"ImageGeneration". + * Bilinen rol son-eki yoksa ad oldugu gibi doner. Bos ada dusmez (rol son-eki + * adin TAMAMIYSA orijinal ad korunur — "Service" -> "Service"). */ +export function baseNameOf(node: CodeNode): string { + const name = node.name; + const suffixes = ROLE_SUFFIX_BY_KIND[node.kindOf()] ?? []; + for (const suf of suffixes) { + if (name.length > suf.length && name.toLowerCase().endsWith(suf.toLowerCase())) { + return name.slice(0, name.length - suf.length); + } + } + return name; +} + +/** Node'un feature klasoru (kebab-case). graph.featureOf -> feature slug veya + * "common"; ir.ts feature-inference TEK KAYNAGIDIR. Yol uretiminin yalniz + * okudugu bir degerdir (heuristik burada NOT). */ +export function featureFolderOf(node: CodeNode, graph: CodeGraph): string { + return graph.featureOf(node) || "common"; +} + +/** Migration sira numarasini 3 haneli sifir-dolgulu dondurur: 1 -> "001". */ +export function migrationSeq(index: number): string { + return String(index + 1).padStart(3, "0"); +} + +/* ── filePathFor: node -> proje kokune goreli POSIX yolu (bas "/" yok) ────── + * KLASOR = feature (graph.featureOf); DOSYA ADI = baseNameOf (rol son-eki + * TEKRARSIZ). Idiomatik NestJS duzeni: + * Module -> /.module.ts (feature basina TEK module) + * Controller -> /.controller.ts (auth.controller.ts) + * Service -> /.service.ts + * Repository -> /.repository.ts (user.repository.ts) + * Model -> /entities/.entity.ts + * DTO -> /dto/.dto.ts (auth-response.dto.ts) + * Enum -> /enums/.enum.ts (feature) | common/enums/... (common) + * Exception -> /exceptions/.exception.ts | common/exceptions/... (common) + * Table -> migrations/NNN_create_.sql (NNN ir tarafindan verilir) + * View -> migrations/NNN_create_.sql (DB view -> SQL; Table gibi kokte) + * Cache -> /.cache.ts + * MessageQueue -> /.queue.ts + * Worker -> /.worker.ts + * EventHandler -> /.handler.ts + * Orchestrator -> /.orchestrator.ts + * ExternalService -> /.client.ts + * Middleware -> /.middleware.ts | common/.middleware.ts + * APIGateway -> /.gateway.ts | common/.gateway.ts + * diger stub -> /stubs/..stub.ts (feature koku temiz) + * + * Tum dosyalar src/ KOKUNE goredir; src/ onekini scaffold/montaj ekler. + * Table/View icin sira numarasi graph.migrationIndexOf(node) ile cozulur. + * ──────────────────────────────────────────────────────────────────────── */ +export function filePathFor(node: CodeNode, graph: CodeGraph): string { + const feature = featureFolderOf(node, graph); + const base = kebabCase(baseNameOf(node)) || kebabCase(node.name) || feature; + switch (node.kindOf()) { + case "Module": + // Feature basina tek module -> dosya adi feature'in kendisidir. + return `${feature}/${feature}.module.ts`; + case "Controller": + return `${feature}/${base}.controller.ts`; + case "Service": + return `${feature}/${base}.service.ts`; + case "Repository": + return `${feature}/${base}.repository.ts`; + case "Model": + return `${feature}/entities/${base}.entity.ts`; + case "DTO": + return `${feature}/dto/${base}.dto.ts`; + case "Enum": + // Paylasimli enum'lar common/; feature'a ozel olanlar feature altinda. + return feature === "common" + ? `common/enums/${base}.enum.ts` + : `${feature}/enums/${base}.enum.ts`; + case "Exception": + return feature === "common" + ? `common/exceptions/${base}.exception.ts` + : `${feature}/exceptions/${base}.exception.ts`; + // Table VE View ikisi de bir SQL migration'dir (CREATE TABLE / CREATE VIEW), + // migrations/ kokunde ve AYNI sira duzeninde (migrationIndexOf View'i kaynak + // Table'larindan sonra yerlestirir). Fiziksel ad tek kaynaktan (tableSqlName; + // tekrar cogullanmaz) — table.emitter / model.emitter ile tutarli. + case "Table": + case "View": { + const seq = migrationSeq(graph.migrationIndexOf(node)); + return `migrations/${seq}_create_${tableSqlName(node.name)}.sql`; + } + case "Cache": + return `${feature}/${base}.cache.ts`; + case "MessageQueue": + return `${feature}/${base}.queue.ts`; + case "Worker": + return `${feature}/${base}.worker.ts`; + case "EventHandler": + return `${feature}/${base}.handler.ts`; + case "Orchestrator": + return `${feature}/${base}.orchestrator.ts`; + case "ExternalService": + return `${feature}/${base}.client.ts`; + case "Middleware": + // Middleware feature'a dusmuyorsa (cross-cutting) common'a iner. + return `${feature}/${base}.middleware.ts`; + case "APIGateway": + // Gateway feature'a dusmuyorsa common'a iner (filePathFor feature='common'). + return `${feature}/${base}.gateway.ts`; + default: + // Desteklenmeyen tip -> stub dosyasi. Feature KOKUNE sacilmaz; ayri bir + // `stubs/` alt klasorune toplanir (gercek kod ile karismasin). + return `${feature}/stubs/${base}.${kebabCase(node.kindOf())}.stub.ts`; + } +} + +/** Bir TS dosyasindan (import yolu icin) uzantisiz POSIX yolu. */ +export function importPathOf(filePath: string): string { + return filePath.replace(/\.tsx?$/, ""); +} + +/** Iki dosya yolu arasinda goreli import yolu uretir (deterministik, POSIX). + * Orn from="users/users.service.ts" to="common/enums/role.enum.ts" + * -> "../common/enums/role.enum". */ +export function relativeImportPath(fromFile: string, toFile: string): string { + const fromDir = fromFile.split("/").slice(0, -1); + const toParts = importPathOf(toFile).split("/"); + let i = 0; + while (i < fromDir.length && i < toParts.length - 1 && fromDir[i] === toParts[i]) i++; + const up = fromDir.slice(i).map(() => ".."); + const down = toParts.slice(i); + const segments = [...up, ...down]; + const joined = segments.join("/"); + return joined.startsWith(".") ? joined : `./${joined}`; +} + +/** Bir kind icin "tek basina" sinif adi son eki (Service/Controller vb. zaten + * isimde olabilir; emitter'lar gerekirse kullanir). */ +export const KIND_CLASS_SUFFIX: Partial> = { + Controller: "Controller", + Service: "Service", + Repository: "Repository", + Module: "Module", + Exception: "Exception", +}; + +/* ── Table'dan SENTEZLENEN entity isim/yolu — TEK SOURCE ─────────────────── + * entity-synthesis.ts, repository.emitter, module.emitter, naming.resolveTypeRef + * hepsi BU iki fonksiyona dayanir (entity sinif adi/dosya yolu tutarli kalsin). + * Burada (naming.ts) tutulur cunku resolveTypeRef bunlara ihtiyac duyar ve + * naming.ts emitter'lardan import EDEMEZ (dongu). entity-synthesis bunlari + * re-export eder (geriye-uyum). ──────────────────────────────────────────── */ + +/** Bir Table node'undan SENTEZLENEN entity sinif adi (tekil-pascal). "Users" + * -> "User", "generated_images" -> "GeneratedImage". */ +export function entityClassNameForTable(table: CodeNode): string { + return pascalCase(singularize(table.name)); +} + +/** Bir Table node'unun SENTEZLENEN entity dosya yolu: + * /entities/.entity.ts. */ +export function synthEntityFilePath(table: CodeNode, graph: CodeGraph): string { + const feature = featureFolderOf(table, graph); + const base = kebabCase(singularize(table.name)) || kebabCase(table.name) || feature; + return `${feature}/entities/${base}.entity.ts`; +} + +/** View node'unun TS @ViewEntity dosya yolu — migration'dan AYRI (filePathFor(View) + * SQL migration'i verir). Repository bir View'i tip olarak dondurdugunde bu sinif + * import edilir. */ +export function viewEntityFilePath(view: CodeNode, graph: CodeGraph): string { + const feature = featureFolderOf(view, graph); + const base = kebabCase(view.name) || feature; + return `${feature}/entities/${base}.view.ts`; +} + +/** Cok basit deterministik tekillestirme (sozluk NONE). "users"->"user", + * "categories"->"category", "boxes"->"box". Yalniz son segment tekillestirilir. */ +export function singularize(input: string): string { + const segments = input.split(/[\s\-_./]+/).filter((w) => w.length > 0); + const last = segments.length > 0 ? segments[segments.length - 1] : input; + const prefix = input.slice(0, input.length - last.length); + const lower = last.toLowerCase(); + let singular = last; + if (lower.endsWith("ies") && last.length > 3) { + singular = last.slice(0, -3) + "y"; + } else if ( + lower.endsWith("ses") || + lower.endsWith("xes") || + lower.endsWith("zes") || + lower.endsWith("ches") || + lower.endsWith("shes") + ) { + singular = last.slice(0, -2); + } else if (lower.endsWith("s") && !lower.endsWith("ss") && last.length > 1) { + singular = last.slice(0, -1); + } + return prefix + singular; +} diff --git a/apps/server/src/codegen/openapi.emitter.spec.ts b/apps/server/src/codegen/openapi.emitter.spec.ts new file mode 100644 index 0000000..2d53b31 --- /dev/null +++ b/apps/server/src/codegen/openapi.emitter.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { buildCodeGraph } from "./ir"; +import { projectOpenApi } from "./openapi.emitter"; +import type { StoredNode } from "../nodes/nodes.repository"; + +let seq = 0; +const uuid = () => `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`; +function node(type: StoredNode["type"], properties: Record): StoredNode { + return { id: uuid(), type, projectId: "p", positionX: 0, positionY: 0, homeTabId: "t", + createdAt: "2026-06-29T00:00:00.000Z", updatedAt: "2026-06-29T00:00:00.000Z", version: 1, properties }; +} +function fixture() { + const ctrl = node("Controller", { + ControllerName: "UsersController", Description: "User ops", BaseRoute: "/users", + Endpoints: [ + { HttpMethod: "POST", Route: "/", RequestDTORef: "CreateUserDto", ResponseDTORef: "UserDto", RequiresAuth: true, RequiredRoles: [], PathParams: [], QueryParams: [], StatusCodes: [{ Code: 201, Description: "Created" }] }, + { HttpMethod: "GET", Route: "/:id", ResponseDTORef: "UserDto", RequiresAuth: false, PathParams: [{ Name: "id", DataType: "string" }], QueryParams: [], StatusCodes: [] }, + ], + }); + return buildCodeGraph([ctrl], []); +} + +describe("projectOpenApi — paths", () => { + it("emits an operation per endpoint with method, full path, params, security, tags", () => { + const doc = projectOpenApi(fixture()); + expect(doc.openapi).toMatch(/^3\.1/); + expect(doc.paths["/users"]?.post).toBeTruthy(); + expect(doc.paths["/users/{id}"]?.get).toBeTruthy(); + const post = doc.paths["/users"]!.post!; + expect(post.tags).toContain("UsersController"); + expect(post.security?.length).toBeGreaterThan(0); // RequiresAuth → security + expect((post.responses as Record)["201"]).toBeTruthy(); + const get = doc.paths["/users/{id}"]!.get!; + expect(get.parameters?.some((p) => (p as { name: string }).name === "id")).toBe(true); + expect(get.security ?? []).toHaveLength(0); // public + }); +}); + +describe("projectOpenApi — component schemas", () => { + it("emits a schema per DTO with typed/required fields, enum and nested $refs", () => { + const dto = node("DTO", { Name: "CreateUserDto", Description: "New user", Fields: [ + { Name: "email", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [{ Rule: "Email" }] }, + { Name: "role", DataType: "string", IsRequired: false, IsArray: false, EnumRef: "UserRole" }, + { Name: "tags", DataType: "string", IsRequired: false, IsArray: true, ValidationRules: [] }, + ] }); + const en = node("Enum", { Name: "UserRole", Values: [{ Key: "ADMIN" }, { Key: "USER" }] }); + const graph = buildCodeGraph([dto, en], []); + const doc = projectOpenApi(graph); + const s = doc.components!.schemas!["CreateUserDto"] as { type: string; required?: string[]; properties: Record }; + expect(s.type).toBe("object"); + expect(s.required).toContain("email"); + expect(s.required ?? []).not.toContain("role"); + expect(s.properties.email.format).toBe("email"); + expect(s.properties.role.$ref).toBe("#/components/schemas/UserRole"); + expect(s.properties.tags.type).toBe("array"); + expect(doc.components!.schemas!["UserRole"]).toBeTruthy(); // enum schema present + }); +}); diff --git a/apps/server/src/codegen/openapi.emitter.ts b/apps/server/src/codegen/openapi.emitter.ts new file mode 100644 index 0000000..6cf15a1 --- /dev/null +++ b/apps/server/src/codegen/openapi.emitter.ts @@ -0,0 +1,112 @@ +import type { OpenAPIObject } from "@nestjs/swagger"; +import { propsOf, type CodeGraph } from "./ir"; +import { pascalCase } from "./naming"; + +/* ──────────────────────────────────────────────────────────────────────── + * openapi.emitter.ts — deterministic graph -> OpenAPI 3.1 projection. + * + * Mirrors simple-projection.ts: a pure, verified projection of the CodeGraph. + * No throws (the graph may be partial) and no AI here — structure is derived + * solely from Controller nodes (paths/operations) and, in Task 2, DTO/Enum + * nodes (component schemas). AI enrichment (prose/examples) lands later and + * only annotates EXISTING operations/schemas; it never invents paths. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Convert a Nest-style path (BaseRoute + Route, ":id") to an OpenAPI path ("{id}"). */ +function fullPath(base: string, route: string): string { + const join = `${base ?? ""}/${route ?? ""}`.replace(/\/+/g, "/").replace(/\/$/, "") || "/"; + return join.replace(/:([A-Za-z0-9_]+)/g, "{$1}"); +} + +export function projectOpenApi(graph: CodeGraph): OpenAPIObject { + const paths: Record> = {}; + const tags: { name: string; description?: string }[] = []; + + for (const ctrl of graph.allOf("Controller")) { + const p = propsOf<"Controller">(ctrl); + tags.push({ name: ctrl.name, description: p.Description }); + for (const ep of p.Endpoints ?? []) { + const path = fullPath(p.BaseRoute, ep.Route); + const method = ep.HttpMethod.toLowerCase(); + const parameters = [ + ...(ep.PathParams ?? []).map((pp) => ({ name: pp.Name, in: "path", required: true, schema: { type: "string" } })), + ...(ep.QueryParams ?? []).map((qp) => ({ name: qp.Name, in: "query", required: false, schema: { type: "string" } })), + ]; + const responses: Record = {}; + const codes = (ep.StatusCodes ?? []).length ? ep.StatusCodes! : [{ Code: ep.HttpMethod === "POST" ? 201 : 200, Description: "OK" }]; + for (const sc of codes) { + responses[String(sc.Code)] = { + description: sc.Description ?? "Response", + ...(ep.ResponseDTORef && sc.Code < 400 + ? { content: { "application/json": { schema: ep.ReturnsCollection + ? { type: "array", items: { $ref: `#/components/schemas/${pascalCase(ep.ResponseDTORef)}` } } + : { $ref: `#/components/schemas/${pascalCase(ep.ResponseDTORef)}` } } } } + : {}), + }; + } + const op: Record = { + operationId: `${ctrl.name}_${method}_${path}`.replace(/[^A-Za-z0-9]+/g, "_"), + tags: [ctrl.name], + summary: ep.Description ?? `${ep.HttpMethod} ${path}`, + parameters, + responses, + ...(ep.RequiresAuth ? { security: [{ bearer: [] }] } : {}), + ...(ep.RequestDTORef ? { requestBody: { required: true, content: { "application/json": { schema: { $ref: `#/components/schemas/${pascalCase(ep.RequestDTORef)}` } } } } } : {}), + }; + (paths[path] ??= {})[method] = op; + } + } + + // ── components.schemas: a JSON Schema per DTO/Enum ─────────────────────── + // Each DTO field maps to a property: DataType -> type, IsArray -> array, + // IsRequired -> required[], ValidationRules -> min/max/format/pattern, + // EnumRef/NestedDTORef -> $ref. Enums become string enum schemas. No throws — + // unresolved refs fall back to the raw name (the graph may be partial). + const schemas: Record = {}; + const DT: Record = { + string: { type: "string" }, int: { type: "integer" }, integer: { type: "integer" }, + number: { type: "number" }, float: { type: "number" }, boolean: { type: "boolean" }, + bool: { type: "boolean" }, date: { type: "string", format: "date-time" }, + datetime: { type: "string", format: "date-time" }, uuid: { type: "string", format: "uuid" }, + }; + const ruleToSchema = (rule: string, value?: string): Record => { + switch (rule) { + case "Min": return { minimum: Number(value) }; case "Max": return { maximum: Number(value) }; + case "MinLength": return { minLength: Number(value) }; case "MaxLength": return { maxLength: Number(value) }; + case "Email": return { format: "email" }; case "Url": return { format: "uri" }; + case "Regex": case "Pattern": return value ? { pattern: value } : {}; default: return {}; + } + }; + for (const dto of graph.allOf("DTO")) { + const dp = propsOf<"DTO">(dto); + const properties: Record = {}; + const required: string[] = []; + for (const f of dp.Fields ?? []) { + let schema: Record; + if (f.EnumRef) { const e = graph.resolveRef("Enum", f.EnumRef); schema = { $ref: `#/components/schemas/${pascalCase(e ? e.name : f.EnumRef)}` }; } + else if (f.NestedDTORef) { const n = graph.resolveRef("DTO", f.NestedDTORef); schema = { $ref: `#/components/schemas/${pascalCase(n ? n.name : f.NestedDTORef)}` }; } + else { schema = { ...(DT[f.DataType.toLowerCase()] ?? { type: "string" }) }; for (const r of f.ValidationRules ?? []) Object.assign(schema, ruleToSchema(r.Rule, r.Value)); } + properties[f.Name] = f.IsArray ? { type: "array", items: schema } : schema; + if (f.IsRequired) required.push(f.Name); + if (f.Description) (properties[f.Name] as Record).description = f.Description; + } + schemas[pascalCase(dto.name)] = { type: "object", properties, ...(required.length ? { required } : {}), ...(dp.Description ? { description: dp.Description } : {}) }; + } + for (const en of graph.allOf("Enum")) { + const ep = propsOf<"Enum">(en); + schemas[pascalCase(en.name)] = { type: "string", enum: (ep.Values ?? []).map((v) => v.Value ?? v.Key) }; + } + // (Models: emit as object schemas the same way if referenced; DTOs cover the + // API surface for v1.) + + return { + openapi: "3.1.0", + info: { title: "API", version: "1.0.0" }, + tags, + paths: paths as OpenAPIObject["paths"], + components: { + securitySchemes: { bearer: { type: "http", scheme: "bearer", bearerFormat: "JWT" } }, + schemas: schemas as NonNullable["schemas"], + }, + }; +} diff --git a/apps/server/src/codegen/simple-projection.spec.ts b/apps/server/src/codegen/simple-projection.spec.ts new file mode 100644 index 0000000..794bed8 --- /dev/null +++ b/apps/server/src/codegen/simple-projection.spec.ts @@ -0,0 +1,145 @@ +/** simple-projection — CodeGraph -> Simple View (non-dev) projection. + * System map (feature boxes + dependsOn arrows) is FULLY deterministic; + * capabilities are honestly derived from endpoints + RequiresAuth guard. */ + +import { describe, it, expect } from "vitest"; +import { buildCodeGraph } from "./ir"; +import { projectSimpleView, projectSimpleSketchModel } from "./simple-projection"; +import type { StoredNode } from "../nodes/nodes.repository"; +import type { StoredEdge } from "../edges/edges.repository"; +import type { NodeKind } from "../nodes/schemas"; +import type { EdgeKind } from "../edges/schemas/edge.schema"; + +let idSeq = 0; +const uuid = () => `00000000-0000-4000-8000-${String(++idSeq).padStart(12, "0")}`; + +function node(type: NodeKind, properties: Record): StoredNode { + return { + id: uuid(), type, projectId: "11111111-1111-4111-8111-111111111111", + positionX: 0, positionY: 0, homeTabId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", version: 1, properties, + }; +} +function edge(kind: EdgeKind, s: StoredNode, t: StoredNode): StoredEdge { + return { + id: uuid(), projectId: "11111111-1111-4111-8111-111111111111", + sourceNodeId: s.id, targetNodeId: t.id, kind, + createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", properties: { IsAsync: false }, + }; +} + +/** auth (register, public) + messaging (send message, authed; uses UserRepository + * cross-feature -> messaging dependsOn auth). */ +function fixture() { + const authCtrl = node("Controller", { + ControllerName: "AuthController", Description: "x", BaseRoute: "/auth", + Endpoints: [{ HttpMethod: "POST", Route: "/register", RequiresAuth: false, RequestDTORef: "RegisterDto" }], + }); + const authSvc = node("Service", { ServiceName: "AuthService", Description: "x", Dependencies: [], Methods: [] }); + const userRepo = node("Repository", { RepositoryName: "UserRepository", Description: "x", EntityReference: "User", CustomQueries: [] }); + const userModel = node("Model", { ClassName: "User", Description: "x", Fields: [] }); + + const msgCtrl = node("Controller", { + ControllerName: "MessageController", Description: "x", BaseRoute: "/messages", + Endpoints: [ + { HttpMethod: "POST", Route: "/messages", RequiresAuth: true, RequestDTORef: "SendMessageDto", ResponseDTORef: "MessageDto" }, + { HttpMethod: "GET", Route: "/messages", RequiresAuth: true, ReturnsCollection: true }, + ], + }); + const msgSvc = node("Service", { ServiceName: "MessageService", Description: "x", Dependencies: [{ Kind: "Repository", Ref: "UserRepository" }], Methods: [] }); + const msgRepo = node("Repository", { RepositoryName: "MessageRepository", Description: "x", EntityReference: "Message", CustomQueries: [] }); + const msgModel = node("Model", { ClassName: "Message", Description: "x", Fields: [] }); + + const edges = [ + edge("CALLS", authCtrl, authSvc), + edge("CALLS", authSvc, userRepo), + edge("CALLS", msgCtrl, msgSvc), + edge("CALLS", msgSvc, msgRepo), + ]; + return buildCodeGraph([authCtrl, authSvc, userRepo, userModel, msgCtrl, msgSvc, msgRepo, msgModel], edges); +} + +describe("projectSimpleView — system map (deterministic)", () => { + it("feature boxes + tier (auth base=0, messaging=1)", () => { + const m = projectSimpleView(fixture()); + const auth = m.features.find((f) => f.slug === "auth")!; + const msg = m.features.find((f) => f.slug === "messaging")! ?? m.features.find((f) => f.slug === "message")!; + expect(auth).toBeTruthy(); + expect(auth.title).toBe("Auth"); + expect(auth.tier).toBe(0); + expect(msg.tier).toBe(1); // messaging uses UserRepository (auth) -> tier+1 + }); + + it("arrow: messaging → auth 'uses' (dependsOn)", () => { + const m = projectSimpleView(fixture()); + const msgSlug = m.features.find((f) => f.slug === "messaging" || f.slug === "message")!.slug; + const arr = m.arrows.find((a) => a.from === msgSlug && a.to === "auth"); + expect(arr).toBeTruthy(); + expect(arr!.label).toBe("uses"); + }); + + it("DETERMINISM: same graph twice → identical projection", () => { + const g = fixture(); + expect(JSON.stringify(projectSimpleView(g))).toBe(JSON.stringify(projectSimpleView(g))); + }); +}); + +describe("projectSimpleView — capabilities + logic flow", () => { + it("POST endpoint → write capability (actor=logged-in, data=writes)", () => { + const m = projectSimpleView(fixture()); + const msg = m.features.find((f) => f.slug === "messaging" || f.slug === "message")!; + expect(msg.capabilityCount).toBe(2); + const post = msg.capabilities.find((c) => c.action.includes("Creates") || c.action.includes("Message"))!; + expect(post.actor).toBe("Signed-in user"); + expect(post.data[0]).toEqual({ access: "writes", label: "Message" }); + expect(post.hidden).toBeGreaterThanOrEqual(2); // request + response DTO + }); + + it("authed feature → ONE shared 'Signed in?' gate + operation leaves (deduped, no Start/End)", () => { + const m = projectSimpleView(fixture()); + const msg = m.features.find((f) => f.slug === "messaging" || f.slug === "message")!; + const fg = msg.flowGraph!; + // The auth check appears EXACTLY ONCE for the whole feature (no per-endpoint repetition). + const gates = fg.nodes.filter((n) => n.kind === "decision" && n.label === "Signed in?"); + expect(gates.length).toBe(1); + // Both operations are process leaves hanging off the single gate. + expect(fg.nodes.filter((n) => n.kind === "process").length).toBe(2); + expect(fg.edges.filter((e) => e.from === "gate").length).toBe(2); + // No Start/End ceremony, no per-operation outcome terminals. + expect(fg.nodes.some((n) => n.kind === "terminal" || n.kind === "end")).toBe(false); + }); + + it("structured model — colored groups + member ops + GROUP-level cross-feature edge", () => { + const m = projectSimpleSketchModel(fixture()); + // one COLORED group per feature; NO separate feature node (the group region IS the feature box). + expect(m.groups.some((g) => g.id === "auth" && !!g.color)).toBe(true); + const msgGroup = m.groups.find((g) => g.id === "messaging" || g.id === "message")!; + expect(msgGroup).toBeTruthy(); + expect(m.nodes.some((n) => n.kind === "feature")).toBe(false); + // op nodes hang under their feature group; the gate is a decision kind. + expect(m.nodes.some((n) => n.kind === "decision")).toBe(true); + expect(m.nodes.every((n) => !n.group || m.groups.some((g) => g.id === n.group))).toBe(true); + // cross-feature dependency arrow connects the GROUP ids (the group acts as a box). + expect(m.edges.some((e) => e.from === msgGroup.id && e.to === "auth" && e.label === "uses")).toBe(true); + // every edge endpoint is a real node id OR a group id. + const ids = new Set([...m.nodes.map((n) => n.id), ...m.groups.map((g) => g.id)]); + expect(m.edges.every((e) => ids.has(e.from) && ids.has(e.to))).toBe(true); + }); + + it("public feature → NO auth gate, but its action still flows to the data it saves", () => { + const m = projectSimpleView(fixture()); + const auth = m.features.find((f) => f.slug === "auth")!; + const fg = auth.flowGraph!; + // No fabricated auth check for a public feature. + expect(fg.nodes.some((n) => n.kind === "decision")).toBe(false); + const procs = fg.nodes.filter((n) => n.kind === "process"); + expect(procs.length).toBe(1); // POST /register + expect(fg.edges.some((e) => e.from === "gate")).toBe(false); // not gated + // DFD enrichment: a data store + a labeled "Saves" flow from the action into it. + const store = fg.nodes.find((n) => n.kind === "data")!; + expect(store).toBeTruthy(); + const flow = fg.edges.find((e) => e.from === procs[0]!.id && e.to === store.id)!; + expect(flow.label).toBe("Saves"); + expect(auth.capabilities[0]!.actor).toBe("Any user"); + }); +}); diff --git a/apps/server/src/codegen/simple-projection.ts b/apps/server/src/codegen/simple-projection.ts new file mode 100644 index 0000000..b448724 --- /dev/null +++ b/apps/server/src/codegen/simple-projection.ts @@ -0,0 +1,532 @@ +/** simple-projection.ts — TECHNICAL graph -> "Simple View" projection (non-dev). + * + * Sibling to Solarch's Mermaid/SQL export: DETERMINISTIC, READ-ONLY SystemMap from + * canonical CodeGraph. Frontend (src/features/simple) renders it. + * No separate state -> no drift; projection changes when graph changes. + * + * TWO LEVELS: + * A) System Map: feature boxes + "uses"(dependsOn)/"triggers"(pub→sub) + * arrows. FULLY deterministic — SAME Feature model codegen's NestJS module wiring + * produces (features()/dependsOn/forwardRefDeps). + * B) Capability list: each Controller endpoint -> simple capability card + + * logic diagram (flowchart). Technical chain (Controller→Service→Repo→Table) + * collapsed; DTO/Cache/Middleware hidden -> "+N details". + * + * HONESTY: only graph-modeled facts are drawn. Decision node ONLY from real + * condition (RequiresAuth -> auth-guard). Business-logic conditions (in filled method + * bodies) NOT in GRAPH so NOT invented. Verb labels from deterministic table + * (no LLM); endpoint.Description preferred when present. */ + +import type { CodeGraph, CodeNode, Feature } from "./ir"; +import { propsOf } from "./ir"; + +/* ── DTOs (structurally matches frontend src/features/simple/types.ts) ──── */ + +export type DataAccess = "writes" | "reads"; +export type FlowNodeKind = "terminal" | "process" | "decision" | "data" | "external" | "end" | "state"; + +export interface CapabilityDatumDTO { access: DataAccess; label: string } +export interface FlowNodeDTO { id: string; kind: FlowNodeKind; label: string; access?: DataAccess } +export interface FlowEdgeDTO { from: string; to: string; label?: string } +export interface CapabilityFlowDTO { nodes: FlowNodeDTO[]; edges: FlowEdgeDTO[] } +export interface CapabilityDTO { + actor: string; + action: string; + data: CapabilityDatumDTO[]; + triggers?: string[]; + external?: string[]; + hidden: number; +} +export interface FeatureBoxDTO { + slug: string; + title: string; + tier: number; + capabilityCount: number; + dataLabels: string[]; + external?: string[]; + capabilities: CapabilityDTO[]; + /** ONE consolidated flowchart for the whole feature: a single shared auth gate + * ("Signed in?") + each operation as a leaf. Operations sharing the auth check + * are NOT repeated; no Start/End ceremony. Undefined when the feature has no + * endpoints. */ + flowGraph?: CapabilityFlowDTO; +} +export interface FeatureArrowDTO { from: string; to: string; label: string; mutual?: boolean } +export interface SystemMapDTO { + features: FeatureBoxDTO[]; + arrows: FeatureArrowDTO[]; + shared?: { items: string[] }; +} + +/* ── Dictionary + helpers (deterministic) ──────────────────────────────── */ + +/** slug -> Title Case ("user-profile" -> "User Profile"). */ +function titleOf(slug: string): string { + return slug + .split(/[-_]/) + .filter(Boolean) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(" "); +} + +/** Reduce a single identifier to human-readable singular noun ("messages" -> "Message"). */ +function objectOf(raw: string): string { + let s = raw.replace(/[^A-Za-z0-9]+/g, " ").trim(); + if (!s) return ""; + // simple singularization (plain): "ies"->"y", drop trailing "s". + if (/ies$/i.test(s)) s = s.replace(/ies$/i, "y"); + else if (/[^s]s$/i.test(s)) s = s.replace(/s$/i, ""); + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** Meaningful noun from route ("/messages/:id" -> "Message"); else controller name. */ +function routeObject(route: string, controllerName: string): string { + const seg = route + .split("/") + .map((p) => p.trim()) + .filter((p) => p && !p.startsWith(":") && !p.startsWith("{")); + const last = seg[seg.length - 1]; + if (last) return objectOf(last); + return objectOf(controllerName.replace(/Controller$/i, "")); +} + +type EndpointProps = { HttpMethod: string; Route: string; RequiresAuth: boolean; ReturnsCollection?: boolean; RequestDTORef?: string; ResponseDTORef?: string; MiddlewareRefs?: string[]; Description?: string }; + +/** Last non-param route segment, title-cased ("/complaints" → "Complaints"); the + * collection name as written (usually plural) — for "Lists …". */ +function collectionOf(route: string, controllerName: string): string { + const seg = route.split("/").map((p) => p.trim()).filter((p) => p && !p.startsWith(":") && !p.startsWith("{")); + const last = seg[seg.length - 1]; + if (last) { + const s = last.replace(/[^A-Za-z0-9]+/g, " ").trim(); + return s.charAt(0).toUpperCase() + s.slice(1); + } + return objectOf(controllerName.replace(/Controller$/i, "")); +} + +/** Route carries a path parameter (/:id, /{id}) → single-item operation. */ +function hasPathParam(route: string): boolean { + return route.split("/").some((p) => p.startsWith(":") || p.startsWith("{")); +} + +/** "a" / "an" by leading vowel (simple). */ +function article(word: string): string { + return /^[aeiou]/i.test(word) ? "an" : "a"; +} + +/** Endpoint → plain action phrase. Description wins; otherwise a deterministic verb table. */ +function actionSentence(ep: EndpointProps, controllerName: string): string { + if (ep.Description && ep.Description.trim()) return ep.Description.trim(); + const obj = routeObject(ep.Route, controllerName); + const a = article(obj); + // Capability voice — "things a person can do", not system narration. No "by ID" jargon. + switch (ep.HttpMethod) { + case "POST": + return `Creates ${obj}`; + case "PUT": + case "PATCH": + return `Updates ${obj}`; + case "DELETE": + return `Deletes ${obj}`; + default: // GET + return ep.ReturnsCollection ? `Lists ${obj}` : `Gets ${obj}`; + } +} + +const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +/* ── A) System Map ─────────────────────────────────────────────────── */ + +/** dependsOn (eager, excluding forwardRef) chain depth = tier (base left). */ +function computeTiers(features: Feature[]): Map { + const bySlug = new Map(features.map((f) => [f.slug, f])); + const cache = new Map(); + const visit = (slug: string, stack: Set): number => { + if (cache.has(slug)) return cache.get(slug)!; + const f = bySlug.get(slug); + if (!f) return 0; + let t = 0; + stack.add(slug); + for (const d of f.dependsOn) { + if (f.forwardRefDeps.includes(d) || !bySlug.has(d) || stack.has(d)) continue; + t = Math.max(t, 1 + visit(d, stack)); + } + stack.delete(slug); + cache.set(slug, t); + return t; + }; + for (const f of features) visit(f.slug, new Set()); + return cache; +} + +/** Human-readable data labels for a feature (entity + synthesized table names). */ +function dataLabelsOf(f: Feature): string[] { + const out = new Set(); + for (const e of f.entities) out.add(objectOf(e.name)); + for (const t of f.syntheticEntityTables) out.add(objectOf(t.name)); + return [...out].sort(); +} + +/** External service names used by feature. */ +function externalsOf(f: Feature): string[] { + return f.infraProviders + .filter((n) => n.kindOf() === "ExternalService") + .map((n) => n.name) + .sort(); +} + +/** pub→sub "triggers" relations: when another feature's EventHandler SUBSCRIBES to a queue + * this feature PUBLISHES to, source→consumer "triggers". */ +function triggerArrows(graph: CodeGraph): FeatureArrowDTO[] { + const arrows = new Map(); + for (const handler of graph.allOf("EventHandler")) { + const consumer = graph.featureOf(handler); + // Queues this handler listens to (SUBSCRIBES). + for (const sub of graph.outEdges(handler.id, "SUBSCRIBES")) { + const queue = graph.byId(sub.targetNodeId); + if (!queue) continue; + // Nodes that PUBLISH to this queue -> source feature. + for (const pub of graph.inEdges(queue.id, "PUBLISHES")) { + const src = graph.byId(pub.sourceNodeId); + if (!src) continue; + const producer = graph.featureOf(src); + if (producer === consumer || producer === "common" || consumer === "common") continue; + const key = `${producer}->${consumer}`; + if (!arrows.has(key)) arrows.set(key, { from: producer, to: consumer, label: "triggers" }); + } + } + } + return [...arrows.values()]; +} + +/* ── B) Capability'ler ──────────────────────────────────────────────────── */ + +/** One endpoint -> CapabilityDTO (card + logic diagram). */ +function capabilityOf(ep: EndpointProps, controller: CodeNode, feature: Feature): CapabilityDTO { + const controllerName = controller.name; + const isWrite = WRITE_METHODS.has(ep.HttpMethod); + const actor = ep.RequiresAuth ? "Signed-in user" : "Any user"; + const action = actionSentence(ep, controllerName); + + // Data: feature's primary entity + direction by HTTP method (write/read). + const labels = dataLabelsOf(feature); + const primary = labels[0]; + const data: CapabilityDatumDTO[] = primary ? [{ access: isWrite ? "writes" : "reads", label: primary }] : []; + + // Triggers (feature level): other features this one triggers (write only). + const external = externalsOf(feature); + + // Hidden technical details: request/response DTO + middleware + feature cache count. + let hidden = 0; + if (ep.RequestDTORef) hidden++; + if (ep.ResponseDTORef) hidden++; + hidden += (ep.MiddlewareRefs ?? []).length; + hidden += feature.infraProviders.filter((n) => n.kindOf() === "Cache").length; + + return { + actor, + action, + data, + external: external.length > 0 ? external : undefined, + hidden, + }; +} + +/** Feature → ONE consolidated DATA-FLOW diagram (the whole feature is one diagram). + * + * Shaped like a small DFD so it reads as a real flow, not a bare star: + * - a SINGLE shared auth gate ("Signed in?") covers every operation that needs + * sign-in (shown once, never per-endpoint); + * - each operation is a process box hanging off the gate (or standalone if public); + * - the feature's primary data is a STORE (cylinder); every operation links to it + * with a LABELED data-flow ("Saves" for writes, "Reads" for reads) — this is what + * turns a sparse bush into a legible flow a person can follow; + * - outside services are external nodes the feature "Uses". + * Honesty: the gate exists only for a real RequiresAuth guard; stores/externals come + * straight from the graph (entities + ExternalService infra). Nothing is invented. + * Kept deliberately small (primary store only, no extra crossing arrows): research on + * flowchart comprehension shows length + arrow diversity HURT readability. */ +function buildFeatureFlow(feature: Feature): CapabilityFlowDTO | undefined { + const controllers = [...feature.controllers].sort((a, b) => (a.name < b.name ? -1 : 1)); + const ops: { label: string; auth: boolean; write: boolean }[] = []; + const seen = new Set(); + for (const c of controllers) { + const eps = propsOf<"Controller">(c).Endpoints ?? []; + for (const ep of eps) { + const e = ep as EndpointProps; + const label = actionSentence(e, c.name); + if (seen.has(label)) continue; // no repetition + seen.add(label); + ops.push({ label, auth: !!e.RequiresAuth, write: WRITE_METHODS.has(e.HttpMethod) }); + } + } + const externals = externalsOf(feature); + const stores = dataLabelsOf(feature).slice(0, 2); // primary store(s) — kept small on purpose + if (ops.length === 0 && externals.length === 0) return undefined; + + const nodes: FlowNodeDTO[] = []; + const edges: FlowEdgeDTO[] = []; + const authed = ops.filter((o) => o.auth); + const open = ops.filter((o) => !o.auth); + // write = changes data, read = only views it. + const access = (o: { write: boolean }): DataAccess => (o.write ? "writes" : "reads"); + + // The data this part keeps (a store per primary entity) + a labeled flow into it. + stores.forEach((label, i) => nodes.push({ id: `d${i}`, kind: "data", label })); + const primaryStore = stores.length ? "d0" : undefined; + const linkData = (opId: string, write: boolean) => { + if (primaryStore) edges.push({ from: opId, to: primaryStore, label: write ? "Saves" : "Reads" }); + }; + + // One shared auth gate (only if at least one operation requires sign-in). + if (authed.length > 0) { + nodes.push({ id: "gate", kind: "decision", label: "Signed in?" }); + authed.forEach((o, i) => { + const id = `a${i}`; + nodes.push({ id, kind: "process", label: o.label, access: access(o) }); + edges.push({ from: "gate", to: id }); + linkData(id, o.write); + }); + } + // Public operations: directly accessible, no gate. + open.forEach((o, i) => { + const id = `p${i}`; + nodes.push({ id, kind: "process", label: o.label, access: access(o) }); + linkData(id, o.write); + }); + // Outside services this part talks to (Stripe, SendGrid, …) — the feature "Uses" them. + externals.forEach((name, i) => nodes.push({ id: `x${i}`, kind: "external", label: name })); + + return { nodes, edges }; +} + +/** "AccountStatus" / "ACCOUNT_STATUS" / "account-status" → "Account status" (plain). */ +function humanize(raw: string): string { + const s = raw + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") // camelCase → spaced + .replace(/[_-]+/g, " ") // snake / kebab → spaced + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** A feature with NO endpoints (pure data/logic) → a plain-language diagram instead + * of technical wiring. Priority: + * 1) Enum STATE MACHINES → lifecycle flow (states + allowed transitions) — e.g. an + * account's status: Open → Frozen → Closed. This is the honest, deterministic + * counterpart of "is the account open?" (the check IS the status lifecycle). + * 2) else Entities (Models) → "what data this part holds". + * 3) else background infrastructure (queues/workers/cache/external). + * Returns undefined when the feature is genuinely empty. */ +function buildDataFlow(feature: Feature, enums: CodeNode[]): CapabilityFlowDTO | undefined { + const nodes: FlowNodeDTO[] = []; + const edges: FlowEdgeDTO[] = []; + const seen = new Set(); + + // 1) Enum state machines → lifecycle flow (states as steps, transitions as arrows). + for (const en of enums) { + const p = propsOf<"Enum">(en); + const values = p.Values ?? []; + if (values.length === 0) continue; + const sid = (k: string) => `${en.name}::${k}`; + for (const v of values) { + const id = sid(v.Key); + if (seen.has(id)) continue; + seen.add(id); + nodes.push({ id, kind: "state", label: humanize(v.Key) }); + } + for (const t of p.Transitions ?? []) { + for (const to of t.To) edges.push({ from: sid(t.From), to: sid(to) }); + } + } + + // 2) Fallback — what data this part holds (entities as data nodes). + if (nodes.length === 0) { + for (const m of feature.entities) { + const id = `M::${m.name}`; + if (seen.has(id)) continue; + seen.add(id); + nodes.push({ id, kind: "data", label: objectOf(m.name) }); + } + } + + // 3) Fallback — background infrastructure (queues/workers/cache/external). + if (nodes.length === 0) { + for (const n of feature.infraProviders) { + nodes.push({ id: `I::${n.name}`, kind: "process", label: humanize(n.name) }); + } + } + + return nodes.length > 0 ? { nodes, edges } : undefined; +} + +/** All capabilities for a feature (from controller endpoints, sorted). */ +function capabilitiesOf(feature: Feature): CapabilityDTO[] { + const caps: CapabilityDTO[] = []; + const controllers = [...feature.controllers].sort((a, b) => (a.name < b.name ? -1 : 1)); + for (const c of controllers) { + const eps = propsOf<"Controller">(c).Endpoints ?? []; + for (const ep of eps) caps.push(capabilityOf(ep as EndpointProps, c, feature)); + } + return caps; +} + +/* ── Top-level projection ─────────────────────────────────────────────── */ + +/** CodeGraph → deterministic Mermaid flowchart of the Simple View (subgraph per + * feature; node shapes encode kind; cross-feature arrows). This is the deterministic + * baseline the sketch renderer consumes; the AI path (codegen.service) produces a + * richer Mermaid but falls back to this when the LLM is unavailable. */ +export function projectSimpleMermaid(graph: CodeGraph): string { + const map = projectSimpleView(graph); + const esc = (s: string) => s.replace(/["\n]/g, "'"); + const nid = (s: string) => s.replace(/[^a-zA-Z0-9]/g, "_"); + const shape = (kind: FlowNodeKind, id: string, label: string): string => { + const l = `"${esc(label)}"`; + switch (kind) { + case "decision": return `${id}{${l}}`; + case "state": return `${id}([${l}])`; + case "data": return `${id}[(${l})]`; + case "external": return `${id}[/${l}/]`; + default: return `${id}[${l}]`; + } + }; + // NO subgraphs: mermaid-to-excalidraw cannot resolve edges that reference a subgraph id + // ("SubGraph element not found"). Instead, each feature is a top-level node and its + // operations hang off it; cross-feature arrows connect the feature NODES. + const lines = ["flowchart TD"]; + for (const f of map.features) { + const fid = nid(f.slug); + lines.push(` ${fid}["${esc(f.title)}"]`); + const fg = f.flowGraph; + if (fg && fg.nodes.length > 0) { + for (const n of fg.nodes) lines.push(` ${shape(n.kind, `${fid}__${nid(n.id)}`, n.label)}`); + for (const e of fg.edges) { + const lbl = e.label ? `|${esc(e.label)}|` : ""; + lines.push(` ${fid}__${nid(e.from)} -->${lbl} ${fid}__${nid(e.to)}`); + } + // connect the feature node to its entry nodes (those with no incoming inner edge); + // an outside service reads as the feature "Uses" it. + const hasIncoming = new Set(fg.edges.map((e) => e.to)); + const entries = fg.nodes.filter((n) => !hasIncoming.has(n.id)); + for (const n of (entries.length ? entries : fg.nodes.slice(0, 1))) { + const lbl = n.kind === "external" ? "|Uses|" : ""; + lines.push(` ${fid} -->${lbl} ${fid}__${nid(n.id)}`); + } + } else { + lines.push(` ${fid}__x["${f.capabilityCount} things you can do"]`); + lines.push(` ${fid} --> ${fid}__x`); + } + } + for (const a of map.arrows) { + const lbl = a.label ? `|${esc(a.label)}|` : ""; + lines.push(` ${nid(a.from)} -->${lbl} ${nid(a.to)}`); + } + return lines.join("\n"); +} + +/** CodeGraph -> Simple View SystemMap (deterministic, pure). */ +export function projectSimpleView(graph: CodeGraph): SystemMapDTO { + const features = graph.features(); + const tiers = computeTiers(features); + + // Enums grouped by feature (Feature has no enums field — they live in the graph). + const enumsByFeature = new Map(); + for (const en of graph.allOf("Enum")) { + const slug = graph.featureOf(en); + if (!slug) continue; + const arr = enumsByFeature.get(slug) ?? []; + arr.push(en); + enumsByFeature.set(slug, arr); + } + + const featureBoxes: FeatureBoxDTO[] = features.map((f) => { + const caps = capabilitiesOf(f); + const external = externalsOf(f); + return { + slug: f.slug, + title: titleOf(f.slug), + tier: tiers.get(f.slug) ?? 0, + capabilityCount: caps.length, + dataLabels: dataLabelsOf(f), + external: external.length > 0 ? external : undefined, + capabilities: caps, + // Endpoints → operations flow; otherwise (pure data/logic feature) → state/data flow. + flowGraph: buildFeatureFlow(f) ?? buildDataFlow(f, enumsByFeature.get(f.slug) ?? []), + }; + }); + + // Arrows: dependsOn ("uses", forwardRef -> mutual) + pub->sub ("triggers"). + const arrows: FeatureArrowDTO[] = []; + for (const f of features) { + for (const dep of f.dependsOn) { + arrows.push({ from: f.slug, to: dep, label: "uses", mutual: f.forwardRefDeps.includes(dep) }); + } + } + const known = new Set(features.map((f) => f.slug)); + for (const t of triggerArrows(graph)) { + if (known.has(t.from) && known.has(t.to)) arrows.push(t); + } + + // Shared (common) infrastructure. + const common = graph.commonFeature(); + const shared = common + ? { items: [...new Set([...common.infraProviders, ...common.services, ...common.repositories].map((n) => n.name))].sort() } + : undefined; + + return { + features: featureBoxes, + arrows, + ...(shared && shared.items.length > 0 ? { shared } : {}), + }; +} + +/* ── C) Structural sketch model (input for tool-calling generation + ELK layout) ──── */ + +/** A structured, Mermaid-FREE model of the Simple View. The deterministic projector below + * is the grounding + fallback; the AI tool-calling agent refines the PRESENTATION on top of + * it (friendly name, semantic color, flow grouping) — never the structure or kind, which + * stay graph-true. The frontend lays it out with ELK and renders it with rough.js. */ +export type SketchNodeKind = "feature" | "action" | "data" | "decision" | "external" | "state"; +export interface SketchNode { id: string; kind: SketchNodeKind; name: string; group?: string; color?: string } +export interface SketchEdge { from: string; to: string; label?: string } +export interface SketchGroup { id: string; name: string; color?: string } +export interface SimpleSketchModel { nodes: SketchNode[]; edges: SketchEdge[]; groups: SketchGroup[] } + +/** FlowGraph (projection) kind → presentation kind. process→action; terminals collapse to action. */ +const SKETCH_KIND: Record = { + terminal: "action", process: "action", end: "action", + decision: "decision", data: "data", external: "external", state: "state", +}; +/** Deterministic group colors (so the grouping is ALWAYS colored even with AI off; the tool agent + * may recolor). Each feature gets the next hue; the frontend maps the name → a token hue. */ +const GROUP_PALETTE = ["blue", "green", "orange", "purple", "teal", "red", "gray"] as const; + +/** CodeGraph → deterministic SimpleSketchModel. Same structure projectSimpleMermaid encodes, + * but as data: each feature is a GROUP holding a feature node + its op/data/external nodes, + * with labeled flows and cross-feature arrows. No Mermaid text, no subgraphs, no parse step. */ +export function projectSimpleSketchModel(graph: CodeGraph): SimpleSketchModel { + const map = projectSimpleView(graph); + const nid = (s: string) => s.replace(/[^a-zA-Z0-9]/g, "_"); + const nodes: SketchNode[] = []; + const edges: SketchEdge[] = []; + const groups: SketchGroup[] = []; + // No "feature node": the GROUP itself represents the feature (its region is the box). A feature's + // ops/data/external are the group's members; the feature is drawn as the surrounding region. + map.features.forEach((f, fi) => { + const fid = nid(f.slug); + groups.push({ id: fid, name: f.title, color: GROUP_PALETTE[fi % GROUP_PALETTE.length] }); + const fg = f.flowGraph; + if (fg && fg.nodes.length > 0) { + for (const n of fg.nodes) nodes.push({ id: `${fid}__${nid(n.id)}`, kind: SKETCH_KIND[n.kind], name: n.label, group: fid }); + for (const e of fg.edges) edges.push({ from: `${fid}__${nid(e.from)}`, to: `${fid}__${nid(e.to)}`, label: e.label }); + } else { + nodes.push({ id: `${fid}__x`, kind: "action", name: `${f.capabilityCount} things you can do`, group: fid }); + } + }); + // Cross-feature arrows connect the GROUPS (group ids) — they read as edges between the group + // "boxes" (exiting the region, not an inner box), which keeps the groups cleanly aligned. + for (const a of map.arrows) edges.push({ from: nid(a.from), to: nid(a.to), label: a.label }); + return { nodes, edges, groups }; +} diff --git a/apps/server/src/codegen/surgical-fill.repository.ts b/apps/server/src/codegen/surgical-fill.repository.ts new file mode 100644 index 0000000..a0874ea --- /dev/null +++ b/apps/server/src/codegen/surgical-fill.repository.ts @@ -0,0 +1,60 @@ +import { Injectable } from "@nestjs/common"; +import { Neo4jService } from "../neo4j/neo4j.service"; + +/* ──────────────────────────────────────────────────────────────────────── + * surgical-fill.repository.ts — Persist algorithm bodies filled by Surgical AI + * by REGION (projectId, nodeId, member). + * + * Constructor model: STRUCTURE derived deterministically from graph; ALGORITHM body is + * user IP written by AI (or human), not derivable -> must be stored. Previously never + * stored (fill lived only in frontend state) -> lost on panel close/refresh. Now every + * filled region writes here immediately; generate re-injects these bodies instead of NOT_IMPLEMENTED. + * ──────────────────────────────────────────────────────────────────────── */ + +export interface StoredFill { + nodeId: string; + member: string; + body: string; + filledAt: string; +} + +@Injectable() +export class SurgicalFillRepository { + constructor(private readonly neo4j: Neo4jService) {} + + /** Write/overwrite filled body for a region (nodeId#member) (idempotent). + * filledAt used in re-injection signature (@solarch:filled at=…). */ + async upsert(projectId: string, nodeId: string, member: string, body: string, filledAt: string): Promise { + await this.neo4j.run( + `MERGE (f:SurgicalFill {projectId:$projectId, nodeId:$nodeId, member:$member}) + SET f.body=$body, f.filledAt=$filledAt, f.updatedAt=$now`, + { projectId, nodeId, member, body, filledAt, now: new Date().toISOString() }, + ); + } + + /** All stored bodies for project — for generate re-injection. */ + async getAllForProject(projectId: string): Promise { + const r = await this.neo4j.run( + `MATCH (f:SurgicalFill {projectId:$projectId}) RETURN f`, + { projectId }, + ); + return r.records.map((rec) => { + const p = rec.get("f").properties; + return { nodeId: p.nodeId, member: p.member, body: p.body, filledAt: p.filledAt }; + }); + } + + /** Delete all fills (e.g. "regenerate from scratch"). */ + async deleteForProject(projectId: string): Promise { + await this.neo4j.run(`MATCH (f:SurgicalFill {projectId:$projectId}) DETACH DELETE f`, { projectId }); + } + + /** Delete filled body for ONE region (nodeId#member) — "revert to stub". generate + * then returns NOT_IMPLEMENTED skeleton for that region. Silent if missing (idempotent). */ + async deleteOne(projectId: string, nodeId: string, member: string): Promise { + await this.neo4j.run( + `MATCH (f:SurgicalFill {projectId:$projectId, nodeId:$nodeId, member:$member}) DETACH DELETE f`, + { projectId, nodeId, member }, + ); + } +} diff --git a/apps/server/src/codegen/surgical.spec.ts b/apps/server/src/codegen/surgical.spec.ts new file mode 100644 index 0000000..eb64be3 --- /dev/null +++ b/apps/server/src/codegen/surgical.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { countSurgicalMarkers, surgicalMarker, notImplemented } from "./surgical"; + +/* ──────────────────────────────────────────────────────────────────────── + * surgical.spec.ts — countSurgicalMarkers counts "TO-FILL" regions: + * markers minus filled stamps. When codegen fully produces a region (queue producer) + * and stamps `@solarch:filled by=codegen`, it must NOT count as "to fill" -> UI total + * matches what fill processes. + * (Bug: "starts with 69 instead of 71 on button press" — codegen-filled queue publishes.) + * ──────────────────────────────────────────────────────────────────────── */ + +describe("countSurgicalMarkers — pending (to-fill) region count", () => { + it("NOT_IMPLEMENTED skeletons are counted (classic fill points)", () => { + const body = [ + ` async createUser(): Promise {`, + ` ${surgicalMarker({ nodeId: "n1", member: "createUser" })}`, + ` ${notImplemented("UsersService", "createUser")}`, + ` }`, + ].join("\n"); + expect(countSurgicalMarkers(body)).toBe(1); + }); + + it("codegen-filled region (marker + @solarch:filled by=codegen) is NOT counted", () => { + const body = [ + ` async publish(payload: JobDto): Promise {`, + ` ${surgicalMarker({ nodeId: "n2", member: "publish", deps: ["this.queue"] })}`, + ` // @solarch:filled by=codegen`, + ` await this.queue.add("publish", payload);`, + ` }`, + ].join("\n"); + expect(countSurgicalMarkers(body)).toBe(0); + }); + + it("mixed file: 2 skeletons + 1 codegen-filled -> 2 (to fill)", () => { + const skel = (m: string) => + [` async ${m}(): Promise {`, ` ${surgicalMarker({ nodeId: "n", member: m })}`, ` ${notImplemented("S", m)}`, ` }`].join("\n"); + const filled = [ + ` async publish(p: JobDto): Promise {`, + ` ${surgicalMarker({ nodeId: "q", member: "publish" })}`, + ` // @solarch:filled by=codegen`, + ` await this.queue.add("publish", p);`, + ` }`, + ].join("\n"); + const content = [skel("a"), skel("b"), filled].join("\n\n"); + expect(countSurgicalMarkers(content)).toBe(2); + }); + + it("returns 0 when no markers", () => { + expect(countSurgicalMarkers("export class X {}\n")).toBe(0); + }); +}); diff --git a/apps/server/src/codegen/surgical.ts b/apps/server/src/codegen/surgical.ts new file mode 100644 index 0000000..fefddbd --- /dev/null +++ b/apps/server/src/codegen/surgical.ts @@ -0,0 +1,72 @@ +/* ──────────────────────────────────────────────────────────────────────── + * surgical.ts — Surgical marker + NOT_IMPLEMENTED body. + * + * Method bodies are "algorithm fields" — Constructor does NOT write them, leaves a structured + * marker. Surgical AI (separate, later stage) fills only these marked + * regions. Marker format is FIXED and machine-parseable. + * + * Format (single-line comment + info lines): + * + * // @solarch:surgical id=# + * // (optional) + * // throws: ExceptionA, ExceptionB (optional) + * // deps: dep1, dep2 (optional) + * + * Body is always: + * throw new Error("NOT_IMPLEMENTED: ."); + * ──────────────────────────────────────────────────────────────────────── */ + +export interface SurgicalMarkerInput { + /** Persistent UUID of the node this marker belongs to. */ + nodeId: string; + /** Method/member name (e.g. "createUser"). */ + member: string; + /** Work description — what it should do (single/multi line; split per line). */ + description?: string; + /** Throwable Exception node Names. */ + throws?: string[]; + /** Accessible dependencies (DI field names / repo / service Names). */ + deps?: string[]; +} + +const MARKER_PREFIX = "@solarch:surgical"; + +/** Emit structured surgical comment block (does NOT include trailing newline — + * caller adds own indent). Determinism: lists written in given ORDER + * (emitter guarantees sort), empty entries dropped. */ +export function surgicalMarker(input: SurgicalMarkerInput): string { + const lines: string[] = [`// ${MARKER_PREFIX} id=${input.nodeId}#${input.member}`]; + + if (input.description) { + for (const raw of input.description.split("\n")) { + const t = raw.trim(); + if (t.length > 0) lines.push(`// ${t}`); + } + } + if (input.throws && input.throws.length > 0) { + lines.push(`// throws: ${input.throws.join(", ")}`); + } + if (input.deps && input.deps.length > 0) { + lines.push(`// deps: ${input.deps.join(", ")}`); + } + return lines.join("\n"); +} + +/** Standard NOT_IMPLEMENTED body line. + * notImplemented("UsersService", "create") -> + * throw new Error("NOT_IMPLEMENTED: UsersService.create"); */ +export function notImplemented(className: string, member: string): string { + return `throw new Error("NOT_IMPLEMENTED: ${className}.${member}");`; +} + +/** Count surgical markers in a content block (for GeneratedFile.surgicalMarkers). + * Single source: emitters use this, do not count manually. */ +export function countSurgicalMarkers(content: string): number { + const markers = content.match(new RegExp(MARKER_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"))?.length ?? 0; + // TO-FILL region count = markers − filled stamps. When codegen DETERMINISTICALLY + // completes a region and stamps `@solarch:filled by=codegen` (e.g. BullMQ queue producer), + // that does NOT count as "to fill". Otherwise displayed total (markers) exceeds what + // fill processes (NOT_IMPLEMENTED skeletons) -> user sees "starting with 69 instead of 71". + const filled = content.match(/@solarch:filled\b/g)?.length ?? 0; + return Math.max(0, markers - filled); +} diff --git a/apps/server/src/codegen/types.ts b/apps/server/src/codegen/types.ts new file mode 100644 index 0000000..b1178c5 --- /dev/null +++ b/apps/server/src/codegen/types.ts @@ -0,0 +1,123 @@ +import type { NodeKind } from "../nodes/schemas"; +import type { CodeGraph, CodeNode } from "./ir"; + +/* ──────────────────────────────────────────────────────────────────────── + * Constructor Codegen — shared types / contract. + * + * TechnicalGraph (nodes + edges) -> DETERMINISTIC NestJS+TypeScript scaffold. + * No AI. All emitters are PURE functions: same graph -> byte-identical output. + * + * This file is "single source" — emitter agents rely only on signatures here and + * in ir.ts / naming.ts / imports.ts / surgical.ts. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Target stack. v1 only emits "nestjs"; type may expand later. */ +export type CodegenTarget = "nestjs"; + +/** Language of a generated file — formatting/lint/snapshot hint. */ +export type GeneratedLanguage = "typescript" | "sql" | "json" | "markdown" | "env"; + +/** A single generated file. `path` is project-root-relative POSIX path (always + * "/" separator, no leading "/"), e.g. "users/users.service.ts". */ +export interface GeneratedFile { + /** Project-root-relative POSIX path (no leading "/"). */ + path: string; + /** Full file content. Ends with single "\n" (POSIX). */ + content: string; + /** Syntax/format hint. */ + language: GeneratedLanguage; + /** Count of surgical markers (@solarch:surgical) in this file. */ + surgicalMarkers: number; + /** Persistent UUID of the node that PRODUCED this file (when from a node-emitter). + * undefined for scaffold (project-wide), synthesized feature module, synthetic entity, etc. + * nodeFiles map is built from this. */ + nodeId?: string; +} + +/** Skipped/stubbed node types and counts (for summary). */ +export type SkippedKinds = Record; + +/** Full codegen output — assembled project. */ +export interface GeneratedProject { + target: CodegenTarget; + files: GeneratedFile[]; + /** node.id -> file paths that node PRODUCED (final paths after assembly, + * e.g. "src/users/users.service.ts" / "migrations/001_create_users.sql"). + * Only node-emitter outputs; excludes scaffold/feature-module/synthetic + * entity and other non-node-bound files. One node may produce multiple files + * (list). Keys + paths deterministically sorted. */ + nodeFiles: Record; + /** Deterministic codegen warnings — generation SUCCEEDED but a structural decision + * is reported to the user (e.g. mutual feature module import detected and + * cycle broken: one direction dropped in A<->B, no forwardRef emitted). Content + * deterministic + sorted by input; empty array when no warnings. (M4) */ + warnings: string[]; + summary: { + /** Constructor version that produced this output (CODEGEN_VERSION). Tags which + * generation scaffold the generated code came from. */ + version: number; + fileCount: number; + nodeCount: number; + surgicalMarkerCount: number; + /** Count breakdown of kinds with emitters but stub output (remaining out-of-scope) + * + kinds not in REGISTRY at all. */ + skippedKinds: SkippedKinds; + }; +} + +/* ──────────────────────────────────────────────────────────────────────── + * EmitterContext — everything an emitter needs when converting a node to code. + * Emitters do no I/O, no store access; work only via this ctx + node + * (purity + determinism guarantee). Built by ir.ts. + * ──────────────────────────────────────────────────────────────────────── */ +export interface EmitterContext { + /** Resolved graph with relationship resolution + indexes. */ + readonly graph: CodeGraph; + /** Target stack (always "nestjs" for now). */ + readonly target: CodegenTarget; +} + +/* ──────────────────────────────────────────────────────────────────────── + * Emitter contract. + * + * Three forms; all PURE functions, all return GeneratedFile[]: + * + * 1) NodeEmitter — converts a node to file(s). (node, ctx) -> GeneratedFile[]. + * Most emitters return one file; some (Module barrel, + * Model+entity etc.) may return several. NEVER throws; + * tolerates missing refs (if ctx.graph.resolveRef returns null, + * skip that line / leave TODO comment). + * + * 2) StubEmitter — for 12 unsupported types. Same signature but semantically + * emits "surgical-marker empty skeleton + edge summary". + * Same type as NodeEmitter; separate name documents intent only. + * REGISTRY stub emitter also slots into NodeEmitter slot. + * + * 3) ScaffoldEmitter — project-level files NOT bound to a node + * (package.json, tsconfig, main.ts, app.module.ts ...). + * (ctx) -> GeneratedFile[]. Single input: ctx. + * + * Emitter agents write (1) and (2); (3) provided by scaffold core. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Pure function converting a node (or stub) to file(s). */ +export type NodeEmitter = (node: CodeNode, ctx: EmitterContext) => GeneratedFile[]; + +/** Pure function emitting stub for unsupported type (type-same as NodeEmitter). */ +export type StubEmitter = NodeEmitter; + +/** Pure function emitting project-level files independent of nodes. */ +export type ScaffoldEmitter = (ctx: EmitterContext) => GeneratedFile[]; + +/** Registered emitter entry for a nodeKind. + * `supported=true` -> full backend-chain emitter (Module/Controller/...). + * `supported=false` -> stub emitter (counted in skippedKinds). */ +export interface EmitterEntry { + kind: NodeKind; + emit: NodeEmitter; + /** when false, written to summary.skippedKinds (not silently dropped). */ + supported: boolean; +} + +/** nodeKind -> emitter mapping. emitters/nestjs/index.ts fills this. */ +export type EmitterRegistry = Partial>; diff --git a/apps/server/src/common/envelope.ts b/apps/server/src/common/envelope.ts new file mode 100644 index 0000000..a518eea --- /dev/null +++ b/apps/server/src/common/envelope.ts @@ -0,0 +1,26 @@ +export interface SuccessEnvelope { + success: true; + data: T; +} + +export interface ErrorDetail { + field: string; + issue: string; +} + +export interface ErrorEnvelope { + success: false; + error: { + code: string; + message: string; + details?: ErrorDetail[]; + }; +} + +export function ok(data: T): SuccessEnvelope { + return { success: true, data }; +} + +export function err(code: string, message: string, details?: ErrorDetail[]): ErrorEnvelope { + return { success: false, error: details ? { code, message, details } : { code, message } }; +} diff --git a/apps/server/src/common/filters/conflict.filter.ts b/apps/server/src/common/filters/conflict.filter.ts new file mode 100644 index 0000000..4bfa68c --- /dev/null +++ b/apps/server/src/common/filters/conflict.filter.ts @@ -0,0 +1,26 @@ +import { ArgumentsHost, Catch, ConflictException, ExceptionFilter } from "@nestjs/common"; +import { err } from "../envelope"; + +@Catch(ConflictException) +export class ConflictFilter implements ExceptionFilter { + catch(exception: ConflictException, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse(); + const res = exception.getResponse() as + | { code?: string; message?: string; suggestion?: string; ruleViolated?: string; docLink?: string; currentVersion?: number; currentRevision?: number } + | string; + const code = typeof res === "object" && res.code ? res.code : "ERR_CONFLICT"; + const message = typeof res === "object" && res.message ? res.message : "Conflict."; + const envelope = err(code, message); + // Preserve Rules Engine rejection core (suggestion/ruleViolated/docLink) and + // version conflict currentVersion on the envelope (PaymentRequiredFilter pattern). + if (typeof res === "object") { + const e = envelope.error as Record; + if (res.suggestion) e.suggestion = res.suggestion; + if (res.ruleViolated) e.ruleViolated = res.ruleViolated; + if (res.docLink) e.docLink = res.docLink; + if (res.currentVersion !== undefined) e.currentVersion = res.currentVersion; + if (res.currentRevision !== undefined) e.currentRevision = res.currentRevision; + } + response.status(409).json(envelope); + } +} diff --git a/apps/server/src/common/filters/forbidden.filter.ts b/apps/server/src/common/filters/forbidden.filter.ts new file mode 100644 index 0000000..36093fe --- /dev/null +++ b/apps/server/src/common/filters/forbidden.filter.ts @@ -0,0 +1,13 @@ +import { ArgumentsHost, Catch, ExceptionFilter, ForbiddenException } from "@nestjs/common"; +import { err } from "../envelope"; + +@Catch(ForbiddenException) +export class ForbiddenFilter implements ExceptionFilter { + catch(exception: ForbiddenException, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse(); + const res = exception.getResponse() as { code?: string; message?: string } | string; + const code = typeof res === "object" && res.code ? res.code : "ERR_FORBIDDEN"; + const message = typeof res === "object" && res.message ? res.message : "You do not have permission to access this resource."; + response.status(403).json(err(code, message)); + } +} diff --git a/apps/server/src/common/filters/internal.filter.spec.ts b/apps/server/src/common/filters/internal.filter.spec.ts new file mode 100644 index 0000000..47ddc8b --- /dev/null +++ b/apps/server/src/common/filters/internal.filter.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from "vitest"; +import { BadRequestException } from "@nestjs/common"; +import { InternalFilter } from "./internal.filter"; + +function makeHostMock() { + const json = vi.fn(); + const status = vi.fn(() => ({ json })); + return { + host: { + switchToHttp: () => ({ getResponse: () => ({ status }) }), + } as any, + status, + json, + }; +} + +describe("InternalFilter", () => { + it("returns HttpException with its own status + code", () => { + const filter = new InternalFilter(); + const { host, status, json } = makeHostMock(); + filter.catch(new BadRequestException({ code: "ERR_X", message: "reason" }), host); + expect(status).toHaveBeenCalledWith(400); + expect(json.mock.calls[0][0].error.code).toBe("ERR_X"); + }); + + it("BadRequest without code (NestJS builtin, e.g. malformed JSON) → ERR_BAD_JSON + raw message does not leak", () => { + const filter = new InternalFilter(); + const { host, status, json } = makeHostMock(); + // NestJS mapExternalException converts body-parser SyntaxError into this BadRequest. + filter.catch(new BadRequestException("Unexpected token } in JSON at position 6 {\"a\": }"), host); + expect(status).toHaveBeenCalledWith(400); + expect(json.mock.calls[0][0].error.code).toBe("ERR_BAD_JSON"); + expect(json.mock.calls[0][0].error.message).not.toContain("position"); // raw parser message must not leak + }); + + it("body-parser PayloadTooLarge (413) → ERR_PAYLOAD_TOO_LARGE", () => { + const filter = new InternalFilter(); + const { host, status, json } = makeHostMock(); + // http-errors shape (not NestJS HttpException) + filter.catch({ statusCode: 413, type: "entity.too.large", message: "too large" }, host); + expect(status).toHaveBeenCalledWith(413); + expect(json.mock.calls[0][0].error.code).toBe("ERR_PAYLOAD_TOO_LARGE"); + }); + + it("malformed JSON (400 entity.parse.failed) → ERR_BAD_JSON", () => { + const filter = new InternalFilter(); + const { host, status, json } = makeHostMock(); + filter.catch({ statusCode: 400, type: "entity.parse.failed", message: "bad json" }, host); + expect(status).toHaveBeenCalledWith(400); + expect(json.mock.calls[0][0].error.code).toBe("ERR_BAD_JSON"); + }); + + it("unknown error → 500 ERR_INTERNAL", () => { + const filter = new InternalFilter(); + const { host, status, json } = makeHostMock(); + filter.catch(new Error("boom"), host); + expect(status).toHaveBeenCalledWith(500); + expect(json.mock.calls[0][0].error.code).toBe("ERR_INTERNAL"); + }); +}); diff --git a/apps/server/src/common/filters/internal.filter.ts b/apps/server/src/common/filters/internal.filter.ts new file mode 100644 index 0000000..7021299 --- /dev/null +++ b/apps/server/src/common/filters/internal.filter.ts @@ -0,0 +1,48 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common"; +import { err } from "../envelope"; + +@Catch() +export class InternalFilter implements ExceptionFilter { + private readonly logger = new Logger(InternalFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse(); + + if (exception instanceof HttpException) { + const status = exception.getStatus(); + const res = exception.getResponse() as { code?: string; message?: string } | string; + const hasCode = typeof res === "object" && !!res.code; + // Our coded exceptions carry `code`. When code is absent this is a NestJS-builtin + // exception (e.g. body-parser SyntaxError → Nest mapExternalException → BadRequest + // with raw message that may reflect request body). Return generic message + ERR_BAD_JSON. + if (status === 400 && !hasCode) { + response.status(400).json(err("ERR_BAD_JSON", "The request body is not valid JSON or is invalid.")); + return; + } + const code = hasCode ? (res as { code: string }).code : `ERR_HTTP_${status}`; + const message = typeof res === "object" && res.message + ? res.message + : (typeof res === "string" ? res : exception.message); + response.status(status).json(err(code, message)); + return; + } + + // body-parser / http-errors (payload too large, malformed JSON, ...) are not HttpException + // → must not become 500; return correct client error code. + const he = exception as { statusCode?: number; status?: number; type?: string }; + const httpStatus = he?.statusCode ?? he?.status; + if (typeof httpStatus === "number" && httpStatus >= 400 && httpStatus < 500) { + if (httpStatus === 413) { + response.status(413).json(err("ERR_PAYLOAD_TOO_LARGE", "The request body is too large (limit: 1MB).")); + } else if (he.type === "entity.parse.failed") { + response.status(400).json(err("ERR_BAD_JSON", "The request body is not valid JSON.")); + } else { + response.status(httpStatus).json(err(`ERR_HTTP_${httpStatus}`, "The request was rejected.")); + } + return; + } + + this.logger.error("Unexpected error", exception instanceof Error ? exception.stack : exception); + response.status(500).json(err("ERR_INTERNAL", "An unexpected error occurred.")); + } +} diff --git a/apps/server/src/common/filters/not-found.filter.ts b/apps/server/src/common/filters/not-found.filter.ts new file mode 100644 index 0000000..d517edd --- /dev/null +++ b/apps/server/src/common/filters/not-found.filter.ts @@ -0,0 +1,13 @@ +import { ArgumentsHost, Catch, ExceptionFilter, NotFoundException } from "@nestjs/common"; +import { err } from "../envelope"; + +@Catch(NotFoundException) +export class NotFoundFilter implements ExceptionFilter { + catch(exception: NotFoundException, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse(); + const res = exception.getResponse() as { code?: string; message?: string } | string; + const code = typeof res === "object" && res.code ? res.code : "ERR_NODE_NOT_FOUND"; + const message = typeof res === "object" && res.message ? res.message : "Record not found."; + response.status(404).json(err(code, message)); + } +} diff --git a/apps/server/src/common/filters/schema-error.filter.spec.ts b/apps/server/src/common/filters/schema-error.filter.spec.ts new file mode 100644 index 0000000..457b969 --- /dev/null +++ b/apps/server/src/common/filters/schema-error.filter.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi } from "vitest"; +import { z, ZodError } from "zod"; +import { SchemaErrorFilter } from "./schema-error.filter"; + +function makeHostMock() { + const json = vi.fn(); + const status = vi.fn(() => ({ json })); + return { + host: { + switchToHttp: () => ({ + getResponse: () => ({ status }), + }), + } as any, + status, + json, + }; +} + +describe("SchemaErrorFilter", () => { + it("converts ZodError to 400 + ERR_SCHEMA_INVALID envelope", () => { + const filter = new SchemaErrorFilter(); + const { host, status, json } = makeHostMock(); + + let zerr: ZodError; + try { + z.object({ name: z.string() }).parse({}); + } catch (e) { + zerr = e as ZodError; + } + + filter.catch(zerr!, host); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ + success: false, + error: { + code: "ERR_SCHEMA_INVALID", + message: "The submitted properties do not match the schema.", + details: expect.any(Array), + }, + }); + + const arg = json.mock.calls[0][0]; + expect(arg.error.details).toHaveLength(1); + expect(arg.error.details[0].field).toBe("name"); + }); + + it("converts nested paths to dotted strings", () => { + const filter = new SchemaErrorFilter(); + const { host, json } = makeHostMock(); + + let zerr: ZodError; + try { + z.object({ properties: z.object({ Columns: z.array(z.object({ Name: z.string() })) }) }) + .parse({ properties: { Columns: [{}] } }); + } catch (e) { + zerr = e as ZodError; + } + + filter.catch(zerr!, host); + const arg = json.mock.calls[0][0]; + expect(arg.error.details[0].field).toBe("properties.Columns.0.Name"); + }); +}); diff --git a/apps/server/src/common/filters/schema-error.filter.ts b/apps/server/src/common/filters/schema-error.filter.ts new file mode 100644 index 0000000..29b1bb0 --- /dev/null +++ b/apps/server/src/common/filters/schema-error.filter.ts @@ -0,0 +1,27 @@ +import { ArgumentsHost, Catch, ExceptionFilter } from "@nestjs/common"; +import { ZodError } from "zod"; +import { ZodValidationException } from "nestjs-zod"; +import { err } from "../envelope"; + +@Catch(ZodError, ZodValidationException) +export class SchemaErrorFilter implements ExceptionFilter { + catch(exception: ZodError | ZodValidationException, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse(); + // nestjs-zod ZodValidationException carries ZodError in `error` field + const zodError: ZodError = + exception instanceof ZodValidationException + ? ((exception as any).error as ZodError) + : exception; + const details = zodError.issues.map((issue) => ({ + field: issue.path.join("."), + issue: issue.message, + })); + response.status(400).json( + err( + "ERR_SCHEMA_INVALID", + "The submitted properties do not match the schema.", + details, + ), + ); + } +} diff --git a/apps/server/src/common/filters/unauthorized.filter.ts b/apps/server/src/common/filters/unauthorized.filter.ts new file mode 100644 index 0000000..3b03a0f --- /dev/null +++ b/apps/server/src/common/filters/unauthorized.filter.ts @@ -0,0 +1,13 @@ +import { ArgumentsHost, Catch, ExceptionFilter, UnauthorizedException } from "@nestjs/common"; +import { err } from "../envelope"; + +@Catch(UnauthorizedException) +export class UnauthorizedFilter implements ExceptionFilter { + catch(exception: UnauthorizedException, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse(); + const res = exception.getResponse() as { code?: string; message?: string } | string; + const code = typeof res === "object" && res.code ? res.code : "ERR_UNAUTHORIZED"; + const message = typeof res === "object" && res.message ? res.message : "Authentication is required."; + response.status(401).json(err(code, message)); + } +} diff --git a/apps/server/src/common/guards/user-throttler.guard.spec.ts b/apps/server/src/common/guards/user-throttler.guard.spec.ts new file mode 100644 index 0000000..d1aefda --- /dev/null +++ b/apps/server/src/common/guards/user-throttler.guard.spec.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { UserThrottlerGuard } from "./user-throttler.guard"; + +describe("UserThrottlerGuard", () => { + let guard: UserThrottlerGuard; + + beforeEach(() => { + guard = new UserThrottlerGuard({} as never, {} as never, {} as never); + }); + + it("tracks req.auth.userId when present", async () => { + const tracker = await guard["getTracker"]({ + auth: { userId: "user_abc", orgId: null, orgRole: null }, + ip: "203.0.113.5", + }); + expect(tracker).toBe("user_abc"); + }); + + it("falls back to IP when auth is missing", async () => { + const tracker = await guard["getTracker"]({ ip: "203.0.113.5" }); + expect(tracker).toBe("203.0.113.5"); + }); + + it("falls back to anon when no userId and no IP", async () => { + const tracker = await guard["getTracker"]({ auth: { orgId: null, orgRole: null } }); + expect(tracker).toBe("anon"); + }); +}); diff --git a/apps/server/src/common/guards/user-throttler.guard.ts b/apps/server/src/common/guards/user-throttler.guard.ts new file mode 100644 index 0000000..e58f27d --- /dev/null +++ b/apps/server/src/common/guards/user-throttler.guard.ts @@ -0,0 +1,13 @@ +import { Injectable } from "@nestjs/common"; +import { ThrottlerGuard } from "@nestjs/throttler"; +import type { AuthContext } from "../../auth/auth.types"; + +/** Rate-limit key: authenticated user when req.auth is set, otherwise IP. */ +@Injectable() +export class UserThrottlerGuard extends ThrottlerGuard { + protected async getTracker(req: Record): Promise { + const auth = (req as { auth?: AuthContext }).auth; + if (auth?.userId) return auth.userId; + return (req.ip as string) ?? "anon"; + } +} diff --git a/apps/server/src/common/pipes/zod-validation.pipe.spec.ts b/apps/server/src/common/pipes/zod-validation.pipe.spec.ts new file mode 100644 index 0000000..5896209 --- /dev/null +++ b/apps/server/src/common/pipes/zod-validation.pipe.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { z, ZodError } from "zod"; +import { ZodValidationPipe } from "./zod-validation.pipe"; + +const Schema = z.object({ name: z.string(), age: z.number() }).strict(); + +describe("ZodValidationPipe", () => { + it("transforms valid body", () => { + const pipe = new ZodValidationPipe(Schema); + expect(pipe.transform({ name: "x", age: 1 })).toEqual({ name: "x", age: 1 }); + }); + + it("throws ZodError on invalid body", () => { + const pipe = new ZodValidationPipe(Schema); + expect(() => pipe.transform({ name: "x" })).toThrow(ZodError); + }); + + it("throws ZodError on unknown field (strict)", () => { + const pipe = new ZodValidationPipe(Schema); + expect(() => pipe.transform({ name: "x", age: 1, extra: "y" })).toThrow(ZodError); + }); +}); diff --git a/apps/server/src/common/pipes/zod-validation.pipe.ts b/apps/server/src/common/pipes/zod-validation.pipe.ts new file mode 100644 index 0000000..c5b4512 --- /dev/null +++ b/apps/server/src/common/pipes/zod-validation.pipe.ts @@ -0,0 +1,11 @@ +import { Injectable, PipeTransform } from "@nestjs/common"; +import type { ZodSchema } from "zod"; + +@Injectable() +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: ZodSchema) {} + + transform(value: unknown): T { + return this.schema.parse(value); + } +} diff --git a/apps/server/src/config/env-check.ts b/apps/server/src/config/env-check.ts new file mode 100644 index 0000000..34b9e9a --- /dev/null +++ b/apps/server/src/config/env-check.ts @@ -0,0 +1,22 @@ +import { env } from "./env"; +import { providerStatus, type LlmProvider } from "../ai/providers/llm.factory"; + +/** Boot-time env health check. Values that default to "" but are REQUIRED for a feature are + * reported one-by-one, so the log explains which feature won't work and why. (Truly required + * vars like NEO4J_* already hard-fail in Zod parse and never reach here.) Silent under test. */ +export function warnMissingEnv(logger: { warn(msg: string): void } = console): void { + if (env.NODE_ENV === "test") return; + + const checks: { ok: boolean; name: string; consequence: string }[] = []; + + // AI providers: warn if the active generation/chat provider is not configured (registry-driven). + const providers = new Set([env.LLM_GENERATION_PROVIDER, env.LLM_CHAT_PROVIDER]); + for (const p of providers) { + const { configured, envHint } = providerStatus(p); + checks.push({ ok: configured, name: envHint, consequence: `AI provider "${p}" is unavailable (/ai/* returns 503)` }); + } + + for (const c of checks) { + if (!c.ok) logger.warn(`[env] ${c.name} is not set — ${c.consequence}.`); + } +} diff --git a/apps/server/src/config/env.spec.ts b/apps/server/src/config/env.spec.ts new file mode 100644 index 0000000..d5f7124 --- /dev/null +++ b/apps/server/src/config/env.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { parseEnv } from "./env"; + +const baseEnv = { + NEO4J_URI: "bolt://localhost:7687", + NEO4J_USER: "neo4j", + NEO4J_PASSWORD: "x", + LLM_GENERATION_PROVIDER: "openai", + LLM_CHAT_PROVIDER: "openai", +} as const; + +describe("parseEnv", () => { + it("throws when NEO4J_URI is missing", () => { + expect(() => parseEnv({ NEO4J_USER: "neo4j", NEO4J_PASSWORD: "x" })).toThrow(); + }); + + it("parses valid env and fills defaults", () => { + const env = parseEnv({ ...baseEnv }); + expect(env.PORT).toBe(4000); + expect(env.NODE_ENV).toBe("development"); + expect(env.CORS_ORIGIN).toBe("http://localhost:3000"); + expect(parseEnv({ ...baseEnv }).CODEGEN_FILL_THROTTLE_LIMIT).toBe(10); + }); + + it("throws when LLM providers are missing", () => { + expect(() => + parseEnv({ NEO4J_URI: "bolt://localhost:7687", NEO4J_USER: "neo4j", NEO4J_PASSWORD: "x" }), + ).toThrow(); + }); + + it("coerces PORT (string → number)", () => { + const env = parseEnv({ ...baseEnv, PORT: "5000" }); + expect(env.PORT).toBe(5000); + }); + + it("fills embedding defaults (local, dim 384)", () => { + const e = parseEnv({ ...baseEnv }); + expect(e.EMBED_PROVIDER).toBe("local"); + expect(e.EMBED_DIM).toBe(384); + expect(e.EMBED_TOP_K).toBe(3); + }); + + it("rejects invalid NEO4J_URI", () => { + expect(() => parseEnv({ ...baseEnv, NEO4J_URI: "not-a-url" })).toThrow(); + }); +}); diff --git a/apps/server/src/config/env.ts b/apps/server/src/config/env.ts new file mode 100644 index 0000000..6fb2a77 --- /dev/null +++ b/apps/server/src/config/env.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; + +const EnvSchema = z.object({ + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + PORT: z.coerce.number().int().positive().default(4000), + // Bind address. Default 127.0.0.1 (single-box: only the local reverse proxy reaches + // the backend). In Docker each service is its own container, so the proxy container + // cannot reach loopback — set HOST=0.0.0.0 there (the port is not published to the host). + HOST: z.string().default("127.0.0.1"), + NEO4J_URI: z.string().url(), + NEO4J_USER: z.string().min(1), + NEO4J_PASSWORD: z.string().min(1), +// Connection pool / timeout — against "connection not available" under load. +// Reasonable for single-box launch; increase if necessary. + NEO4J_MAX_POOL_SIZE: z.coerce.number().int().positive().default(50), + NEO4J_CONNECTION_ACQUISITION_TIMEOUT_MS: z.coerce.number().int().positive().default(60_000), + NEO4J_CONNECTION_TIMEOUT_MS: z.coerce.number().int().positive().default(30_000), + NEO4J_MAX_TX_RETRY_TIME_MS: z.coerce.number().int().positive().default(30_000), + NEO4J_MAX_CONNECTION_LIFETIME_MS: z.coerce.number().int().positive().default(3_600_000), + CORS_ORIGIN: z.string().default("http://localhost:3000"), + + // Self-host local owner id (LocalAuthGuard fallback when no API key is sent). + LOCAL_USER_ID: z.string().default("local_owner"), + + // ── AI providers — required; if the active provider's key is missing, /ai/* returns 503. + // generation = architecture generation + tool calling; chat = instruct/dialogue. + // Set both to the same provider unless you intentionally split tiers. + // (Registry + per-provider quirks live in src/ai/providers/llm.factory.ts.) + LLM_GENERATION_PROVIDER: z.enum([ + "openai", "anthropic", "google", "deepseek", "mistral", "groq", "openrouter", "ollama", "bedrock", "openai-compatible", + ]), + LLM_CHAT_PROVIDER: z.enum([ + "openai", "anthropic", "google", "deepseek", "mistral", "groq", "openrouter", "ollama", "bedrock", "openai-compatible", + ]), + // Optional model override for the ACTIVE provider (else the registry's default model is used). + LLM_MODEL: z.string().optional(), + + // Per-provider keys (first-class LangChain integrations). Set the one you selected. + OPENAI_API_KEY: z.string().optional(), + OPENAI_BASE_URL: z.string().url().optional(), // Azure / OpenAI-compatible override + ANTHROPIC_API_KEY: z.string().optional(), + GOOGLE_API_KEY: z.string().optional(), + MISTRAL_API_KEY: z.string().optional(), + GROQ_API_KEY: z.string().optional(), + OPENROUTER_API_KEY: z.string().optional(), // gateway to 300+ models + OLLAMA_BASE_URL: z.string().url().default("http://localhost:11434"), // local, no key + + // Generic OpenAI-compatible endpoint (xAI, Together, Fireworks, vLLM, LM Studio, ...). + LLM_API_KEY: z.string().optional(), + LLM_BASE_URL: z.string().url().optional(), + + // Bedrock — bedrock-mantle (OpenAI-compatible) endpoint + long-term bearer API key. + BEDROCK_API_KEY: z.string().optional(), + BEDROCK_BASE_URL: z.string().url().optional(), + AWS_REGION: z.string().default("us-east-1"), + BEDROCK_MODEL: z.string().default("moonshotai.kimi-k2.5"), + // DeepSeek + DEEPSEEK_API_KEY: z.string().optional(), + DEEPSEEK_BASE_URL: z.string().url().default("https://api.deepseek.com/v1"), + // Legacy default — newer code uses DEEPSEEK_MODEL_AGENT / DEEPSEEK_MODEL_INSTRUCT. + DEEPSEEK_MODEL: z.string().default("deepseek-v4-flash"), + // Agent mode (architecture generation, tool calling) — high-capability reasoning tier. + DEEPSEEK_MODEL_AGENT: z.string().default("deepseek-v4-pro"), + // Instruct mode (chat, explanation) — fast tier. + DEEPSEEK_MODEL_INSTRUCT: z.string().default("deepseek-v4-flash"), + +// Agent loop round ceiling (1 round = 1 LLM call; one round can loop MULTIPLE tool calls). +// Production does NOT stop when the ceiling is full: 'paused' event + user "Continue" +// continues where it left off (the agent sees the current graph, does not create it again). 120 = reasonable +// one-time step; great architecture is completed with a few "Go"s. + AI_MAX_TURNS: z.coerce.number().int().positive().default(120), + + // Codegen fill endpoint rate limit (requests per minute). + CODEGEN_FILL_THROTTLE_LIMIT: z.coerce.number().int().positive().default(10), + + // ── Embeddings (Phase 4 GraphRAG) ── + // bedrock-mantle does not offer embedding models (chat LLMs only) → local default. + // local = @xenova/transformers (ONNX, offline, CPU). bedrock = future embed model via OpenAIEmbeddings. + EMBED_PROVIDER: z.enum(["local", "bedrock"]).default("local"), + // Multilingual (50+ languages), 384 dim — more accurate cross-locale than + // all-MiniLM-L6-v2 (mainly English). Same size → index unchanged. + EMBED_MODEL: z.string().default("Xenova/paraphrase-multilingual-MiniLM-L12-v2"), + EMBED_DIM: z.coerce.number().int().positive().default(384), + EMBED_TOP_K: z.coerce.number().int().positive().default(3), + EMBED_MIN_SCORE: z.coerce.number().min(0).max(1).default(0.7), +}); + +export type Env = z.infer; + +export function parseEnv(source: NodeJS.ProcessEnv | Record): Env { + return EnvSchema.parse(source); +} + +export const env = parseEnv(process.env); diff --git a/apps/server/src/edge-types/edge-types.controller.ts b/apps/server/src/edge-types/edge-types.controller.ts new file mode 100644 index 0000000..422d4f2 --- /dev/null +++ b/apps/server/src/edge-types/edge-types.controller.ts @@ -0,0 +1,47 @@ +import { Controller, Get, Param } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiParam, ApiResponse } from "@nestjs/swagger"; +import { EdgeTypesService } from "./edge-types.service"; +import { ok } from "../common/envelope"; + +@ApiTags("Edge Types") +@Controller("edge-types") +export class EdgeTypesController { + constructor(private readonly service: EdgeTypesService) {} + + @Get() + @ApiOperation({ + summary: "List all edge types", + description: + "Returns the 16 connection types: `id`, `family` (Communication/Data/Infrastructure/Architecture), description, example `source`/`target` and direction note. The connection-type picker in the UI is fed from this endpoint.", + }) + @ApiResponse({ status: 200, description: "`data: { types: [...], total: 16 }`." }) + listAll() { + const types = this.service.listAll(); + return ok({ types, total: types.length }); + } + + @Get(":edgeKind") + @ApiOperation({ + summary: "Single edge type", + description: "Detail of the given edge type: family, description, example source/target, direction note.", + }) + @ApiParam({ name: "edgeKind", description: "Edge kind (e.g. `CALLS`, `WRITES`, `PUBLISHES`)", example: "CALLS" }) + @ApiResponse({ status: 200, description: "Edge type metadata." }) + @ApiResponse({ status: 404, description: "`ERR_EDGE_TYPE_NOT_FOUND`." }) + getById(@Param("edgeKind") edgeKind: string) { + return ok(this.service.getById(edgeKind)); + } + + @Get(":edgeKind/rule") + @ApiOperation({ + summary: "Architecture rules for the edge type", + description: + "Returns the **allowed** (`allow` — which source→target pairs) and **forbidden** (`deny` — with ERR codes) rules for this edge type.", + }) + @ApiParam({ name: "edgeKind", description: "Edge kind", example: "CALLS" }) + @ApiResponse({ status: 200, description: "allow + deny rule lists." }) + @ApiResponse({ status: 404, description: "`ERR_EDGE_TYPE_NOT_FOUND`." }) + getRules(@Param("edgeKind") edgeKind: string) { + return ok(this.service.getRulesById(edgeKind)); + } +} diff --git a/apps/server/src/edge-types/edge-types.module.ts b/apps/server/src/edge-types/edge-types.module.ts new file mode 100644 index 0000000..267cf42 --- /dev/null +++ b/apps/server/src/edge-types/edge-types.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { EdgeTypesController } from "./edge-types.controller"; +import { EdgeTypesService } from "./edge-types.service"; +import { RulesModule } from "../rules/rules.module"; + +@Module({ + imports: [RulesModule], + controllers: [EdgeTypesController], + providers: [EdgeTypesService], +}) +export class EdgeTypesModule {} diff --git a/apps/server/src/edge-types/edge-types.service.spec.ts b/apps/server/src/edge-types/edge-types.service.spec.ts new file mode 100644 index 0000000..0af19bd --- /dev/null +++ b/apps/server/src/edge-types/edge-types.service.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from "vitest"; +import { NotFoundException } from "@nestjs/common"; +import { EdgeTypesService } from "./edge-types.service"; + +describe("EdgeTypesService", () => { + const mockEngine = { + rulesForEdgeKind: vi.fn(() => ({ allow: [], deny: [] })), + }; + const service = new EdgeTypesService(mockEngine as any); + +it("listAll returns 16 edge type", () => { + const list = service.listAll(); + expect(list).toHaveLength(16); + const ids = list.map((t) => t.id); + expect(ids).toContain("CALLS"); + expect(ids).toContain("PUBLISHES"); + expect(ids).toContain("ROUTES_TO"); + }); + +it("getById returns family + description for CALLS", () => { + const d = service.getById("CALLS"); + expect(d.family).toBe("communication"); + expect(d.familyLabel).toContain("Communication"); + }); + + it("getById bilinmeyen id'de NotFoundException", () => { + expect(() => service.getById("FOO")).toThrow(NotFoundException); + }); + +it("getRulesById engine returns allow + deny list", () => { + const r = service.getRulesById("CALLS"); + expect(r.allow).toBeDefined(); + expect(r.deny).toBeDefined(); + }); +}); diff --git a/apps/server/src/edge-types/edge-types.service.ts b/apps/server/src/edge-types/edge-types.service.ts new file mode 100644 index 0000000..38cd535 --- /dev/null +++ b/apps/server/src/edge-types/edge-types.service.ts @@ -0,0 +1,66 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { EDGE_TYPE_REGISTRY, type EdgeTypeMetadata } from "./registry"; +import type { EdgeKind } from "../edges/schemas/edge.schema"; +import { RulesEngine } from "../rules/rules.engine"; + +export interface EdgeTypeSummary { + id: EdgeKind; + family: string; + familyLabel: string; + description: string; + exampleSource: string; + exampleTarget: string; + directionNote: string; +} + +export interface EdgeTypeRules { + id: EdgeKind; + allow: Array; + deny: Array; +} + +@Injectable() +export class EdgeTypesService { + constructor(private readonly rulesEngine: RulesEngine) {} + + listAll(): EdgeTypeSummary[] { + return Object.values(EDGE_TYPE_REGISTRY).map((m) => this.toSummary(m)); + } + + getById(id: string): EdgeTypeSummary { + return this.toSummary(this.find(id)); + } + + getRulesById(id: string): EdgeTypeRules { + const m = this.find(id); + const r = this.rulesEngine.rulesForEdgeKind(m.id); + return { + id: m.id, + allow: r.allow, + deny: r.deny, + }; + } + + private find(id: string): EdgeTypeMetadata { + const meta = EDGE_TYPE_REGISTRY[id as EdgeKind]; + if (!meta) { + throw new NotFoundException({ + code: "ERR_EDGE_TYPE_NOT_FOUND", + message: `Edge tipi '${id}' bilinmiyor. Mevcut: ${Object.keys(EDGE_TYPE_REGISTRY).join(", ")}.`, + }); + } + return meta; + } + + private toSummary(m: EdgeTypeMetadata): EdgeTypeSummary { + return { + id: m.id, + family: m.family, + familyLabel: m.familyLabel, + description: m.description, + exampleSource: m.exampleSource, + exampleTarget: m.exampleTarget, + directionNote: m.directionNote, + }; + } +} diff --git a/apps/server/src/edge-types/registry.ts b/apps/server/src/edge-types/registry.ts new file mode 100644 index 0000000..eaaa79f --- /dev/null +++ b/apps/server/src/edge-types/registry.ts @@ -0,0 +1,111 @@ +import type { EdgeKind } from "../edges/schemas/edge.schema"; + +export type EdgeFamily = + | "communication" + | "data" + | "infrastructure" + | "architecture"; + +export const EDGE_FAMILY_LABELS: Record = { + communication: "Calls and Communication", + data: "Data and Schema", + infrastructure: "Database and Infrastructure", + architecture: "Architecture and Dependency", +}; + +export interface EdgeTypeMetadata { + id: EdgeKind; + family: EdgeFamily; + familyLabel: string; + description: string; + exampleSource: string; + exampleTarget: string; + directionNote: string; +} + +const make = ( + id: EdgeKind, + family: EdgeFamily, + description: string, + exampleSource: string, + exampleTarget: string, + directionNote: string, +): EdgeTypeMetadata => ({ + id, + family, + familyLabel: EDGE_FAMILY_LABELS[family], + description, + exampleSource, + exampleTarget, + directionNote, +}); + +export const EDGE_TYPE_REGISTRY: Record = { + CALLS: make("CALLS", "communication", + "Synchronous method/function call.", + "Controller", "Service", + "From the caller to the callee."), + REQUESTS: make("REQUESTS", "communication", + "HTTP/RPC request over the network.", + "FrontendApp", "APIGateway", + "From the client to the server."), + PUBLISHES: make("PUBLISHES", "communication", + "Asynchronous event emission.", + "Service", "MessageQueue", + "From the publisher to the queue."), + SUBSCRIBES: make("SUBSCRIBES", "communication", + "Asynchronous event subscription.", + "EventHandler", "MessageQueue", + "From the listener to the source (the arrowhead points to the source)."), + + USES: make("USES", "data", + "The component needs another type (usually a DTO/Schema) to do its job.", + "Controller", "DTO", + "From the user to the used."), + HAS: make("HAS", "data", + "A data structure containing another data structure (composition).", + "Model", "Model", + "From the owner to the content."), + EXTENDS: make("EXTENDS", "data", + "Class or schema inheritance.", + "Model", "Model", + "From the derived type to the base type."), + IMPLEMENTS: make("IMPLEMENTS", "data", + "A class implementing an interface.", + "Service", "Service", + "From the implementer to the interface."), + RETURNS: make("RETURNS", "data", + "The type returned by a function/service.", + "Service", "DTO", + "From the returner to the returned type."), + + QUERIES: make("QUERIES", "infrastructure", + "Read-only from the database (SELECT).", + "Repository", "Table", + "From the reader to the table."), + WRITES: make("WRITES", "infrastructure", + "Write to the database (INSERT/UPDATE/DELETE).", + "Repository", "Table", + "From the writer to the table."), + CACHES_IN: make("CACHES_IN", "infrastructure", + "Writing/reading data to/from the cache.", + "Service", "Cache", + "From the cacher to the cache."), + + DEPENDS_ON: make("DEPENDS_ON", "architecture", + "A component's dependency on another to function.", + "Module", "ExternalService", + "From the dependent to the dependency."), + READS_CONFIG: make("READS_CONFIG", "architecture", + "Reading an environment variable or setting.", + "Service", "EnvironmentVariable", + "From the reader to the source."), + THROWS: make("THROWS", "architecture", + "The exception type a component can throw.", + "Service", "Exception", + "From the thrower to the exception type."), + ROUTES_TO: make("ROUTES_TO", "architecture", + "The Gateway/Load Balancer routing the request to the backend.", + "APIGateway", "Controller", + "From the router to the target."), +}; diff --git a/apps/server/src/edges/dto/create-edge.dto.ts b/apps/server/src/edges/dto/create-edge.dto.ts new file mode 100644 index 0000000..1f1bcf0 --- /dev/null +++ b/apps/server/src/edges/dto/create-edge.dto.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { createZodDto } from "nestjs-zod"; +import { EdgeKindSchema, EdgePropertiesSchema } from "../schemas/edge.schema"; + +// id/createdAt/updatedAt generated server-side — client does not send them. +export const CreateEdgeSchema = z.object({ + projectId: z.string().uuid(), + sourceNodeId: z.string().uuid(), + targetNodeId: z.string().uuid(), + kind: EdgeKindSchema, + properties: EdgePropertiesSchema, +}).strict(); + +export type CreateEdgeInput = z.infer; + +export class CreateEdgeDto extends createZodDto(CreateEdgeSchema) {} diff --git a/apps/server/src/edges/dto/edge-response.dto.ts b/apps/server/src/edges/dto/edge-response.dto.ts new file mode 100644 index 0000000..b6fe27b --- /dev/null +++ b/apps/server/src/edges/dto/edge-response.dto.ts @@ -0,0 +1,30 @@ +import type { Edge } from "../schemas/edge.schema"; +import type { SuccessEnvelope } from "../../common/envelope"; + +export type EdgeResponse = SuccessEnvelope; +export type EdgeListResponse = SuccessEnvelope<{ edges: Edge[]; total: number }>; + +/** Non-blocking rule warning (e.g. WARN_COND_001 empty-tab). Edge is still created; + * warning attached to response for display to user only. */ +export interface EdgeWarning { + code: string; + message: string; + suggestion?: string; +} + +/** Edge creation response. `data.warning` optional — keeps typed OpenAPI client + * in sync with runtime behavior (EdgesService.create returns `Edge & { warning? }`). */ +export type EdgeCreatedResponse = SuccessEnvelope; + +export interface EdgeValidationResult { + isValid: boolean; + engineResult?: { + code: string; + ruleViolated?: string; + message: string; + suggestion?: string; + docLink?: string; + }; +} + +export type EdgeValidationResponse = SuccessEnvelope; diff --git a/apps/server/src/edges/dto/update-edge.dto.ts b/apps/server/src/edges/dto/update-edge.dto.ts new file mode 100644 index 0000000..7c59639 --- /dev/null +++ b/apps/server/src/edges/dto/update-edge.dto.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { createZodDto } from "nestjs-zod"; +import { EdgePropertiesSchema } from "../schemas/edge.schema"; + +// kind / source / target immutable — verilirse service ERR_EDGE_IMMUTABLE reddeder. +export const UpdateEdgeSchema = z.object({ + properties: EdgePropertiesSchema.optional(), + kind: z.string().optional(), + sourceNodeId: z.string().optional(), + targetNodeId: z.string().optional(), +}).strict(); + +export type UpdateEdgeInput = z.infer; + +export class UpdateEdgeDto extends createZodDto(UpdateEdgeSchema) {} diff --git a/apps/server/src/edges/dto/validate-edge.dto.ts b/apps/server/src/edges/dto/validate-edge.dto.ts new file mode 100644 index 0000000..36993bf --- /dev/null +++ b/apps/server/src/edges/dto/validate-edge.dto.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { createZodDto } from "nestjs-zod"; +import { EdgeKindSchema } from "../schemas/edge.schema"; + +// Minimal payload for pre-check — source + target + kind only. +// Phase 2A: nodes-exist + kind validity. Phase 2B: rules engine. +export const ValidateEdgeSchema = z.object({ + sourceNodeId: z.string().uuid(), + targetNodeId: z.string().uuid(), + kind: EdgeKindSchema, +}).strict(); + +export type ValidateEdgeInput = z.infer; + +export class ValidateEdgeDto extends createZodDto(ValidateEdgeSchema) {} diff --git a/apps/server/src/edges/edges.controller.ts b/apps/server/src/edges/edges.controller.ts new file mode 100644 index 0000000..68c0479 --- /dev/null +++ b/apps/server/src/edges/edges.controller.ts @@ -0,0 +1,133 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Query, UseGuards } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiParam, ApiQuery, ApiResponse } from "@nestjs/swagger"; +import { ProjectAccessGuard } from "../auth/project-access.guard"; +import { EdgesService } from "./edges.service"; +import { CreateEdgeDto } from "./dto/create-edge.dto"; +import { UpdateEdgeDto } from "./dto/update-edge.dto"; +import { ValidateEdgeDto } from "./dto/validate-edge.dto"; +import { ok } from "../common/envelope"; +import type { + EdgeResponse, + EdgeCreatedResponse, + EdgeListResponse, + EdgeValidationResponse, +} from "./dto/edge-response.dto"; +import { EDGE_KINDS, type EdgeKind } from "./schemas/edge.schema"; + +@ApiTags("Edges") +@UseGuards(ProjectAccessGuard) +@Controller("projects/:projectId/edges") +export class EdgesController { + constructor(private readonly service: EdgesService) {} + + @Post() + @HttpCode(201) + @ApiOperation({ + summary: "Create a new edge (protected by the Rules Engine)", + description: + "Creates a directed connection between two nodes. The **Rules Engine** applies: a connection that is not in the whitelist or that hits the blacklist is rejected (409). Self-loops and duplicates are also blocked. The source/target node + project must exist.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 201, description: "Edge created — full edge object. If there is a non-blocking rule warning (e.g. `WARN_COND_001` empty-table), `data.warning: { code, message, suggestion? }` is attached." }) + @ApiResponse({ status: 400, description: "`ERR_SCHEMA_INVALID`, `ERR_PROJECT_MISMATCH` or `ERR_EDGE_SELF_LOOP`." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`, `ERR_EDGE_SOURCE_NOT_FOUND` or `ERR_EDGE_TARGET_NOT_FOUND`." }) + @ApiResponse({ status: 409, description: "`ERR_EDGE_DUPLICATE`, `ERR_001..ERR_007` (blacklist), `ERR_COND_001/002` (conditional) or `ERR_NOT_WHITELISTED` (default deny). `error.suggestion` proposes a fix." }) + async create( + @Param("projectId") projectId: string, + @Body() body: CreateEdgeDto, + ): Promise { + const created = await this.service.create(projectId, body as any); + return ok(created); + } + + @Get() + @ApiOperation({ + summary: "List edges", + description: "Returns the edges in the project. Can be filtered (and combined) with `?kind=CALLS`, `?sourceNodeId=...`, `?targetNodeId=...`.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiQuery({ name: "kind", required: false, description: "Edge kind filter (e.g. `CALLS`).", example: "CALLS" }) + @ApiQuery({ name: "sourceNodeId", required: false, description: "Source node UUID filter." }) + @ApiQuery({ name: "targetNodeId", required: false, description: "Target node UUID filter." }) + @ApiResponse({ status: 200, description: "`data: { edges: [...], total }`." }) + async list( + @Param("projectId") projectId: string, + @Query("kind") kind: string | undefined, + @Query("sourceNodeId") sourceNodeId: string | undefined, + @Query("targetNodeId") targetNodeId: string | undefined, + ): Promise { + const filterKind = + kind && (EDGE_KINDS as readonly string[]).includes(kind) ? (kind as EdgeKind) : undefined; + const edges = await this.service.list(projectId, { + kind: filterKind, + sourceNodeId, + targetNodeId, + }); + return ok({ edges, total: edges.length }); + } + + @Get(":edgeId") + @ApiOperation({ summary: "Single edge", description: "Returns the given edge with source/target/kind/properties." }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiParam({ name: "edgeId", description: "Edge UUID" }) + @ApiResponse({ status: 200, description: "Full edge object." }) + @ApiResponse({ status: 404, description: "`ERR_EDGE_NOT_FOUND`." }) + async getById( + @Param("projectId") projectId: string, + @Param("edgeId") edgeId: string, + ): Promise { + const edge = await this.service.getById(projectId, edgeId); + return ok(edge); + } + + @Patch(":edgeId") + @ApiOperation({ + summary: "Update edge (properties only)", + description: + "Only `properties` (Label/IsAsync/Protocol/RetryCount) are updated. `kind`/`sourceNodeId`/`targetNodeId` are **immutable** — `ERR_EDGE_IMMUTABLE`. To change the connection, delete and recreate.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiParam({ name: "edgeId", description: "Edge UUID" }) + @ApiResponse({ status: 200, description: "Updated edge." }) + @ApiResponse({ status: 400, description: "`ERR_SCHEMA_INVALID`, `ERR_EDGE_IMMUTABLE` or `ERR_PATCH_EMPTY`." }) + @ApiResponse({ status: 404, description: "`ERR_EDGE_NOT_FOUND`." }) + async update( + @Param("projectId") projectId: string, + @Param("edgeId") edgeId: string, + @Body() body: UpdateEdgeDto, + ): Promise { + const updated = await this.service.update(projectId, edgeId, body as any); + return ok(updated); + } + + @Delete(":edgeId") + @HttpCode(204) + @ApiOperation({ summary: "Delete edge", description: "Deletes the connection. The nodes are not affected." }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiParam({ name: "edgeId", description: "Edge UUID" }) + @ApiResponse({ status: 204, description: "Deleted (no body)." }) + @ApiResponse({ status: 404, description: "`ERR_EDGE_NOT_FOUND`." }) + async delete( + @Param("projectId") projectId: string, + @Param("edgeId") edgeId: string, + ): Promise { + await this.service.delete(projectId, edgeId); + } + + @Post("validate") + @HttpCode(200) + @ApiOperation({ + summary: "Pre-validate the connection (does not write to the DB)", + description: + "Runs the Rules Engine check **before** an edge is created — writes nothing to the DB. Called while the user drags an arrow in the UI or before the AI creates a connection. Returns `{ isValid, severity?, engineResult? }`; `engineResult.suggestion` proposes a fix.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "Always 200. `data.isValid` true/false — the rule result is in `data.engineResult`." }) + async validate( + @Param("projectId") projectId: string, + @Body() body: ValidateEdgeDto, + ): Promise { + const result = await this.service.validate(projectId, body as any); + return ok(result); + } +} diff --git a/apps/server/src/edges/edges.module.ts b/apps/server/src/edges/edges.module.ts new file mode 100644 index 0000000..43f3a2e --- /dev/null +++ b/apps/server/src/edges/edges.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { EdgesController } from "./edges.controller"; +import { EdgesService } from "./edges.service"; +import { EdgesRepository } from "./edges.repository"; +import { NodesModule } from "../nodes/nodes.module"; +import { RulesModule } from "../rules/rules.module"; +import { ProjectsModule } from "../projects/projects.module"; + +@Module({ + imports: [NodesModule, RulesModule, ProjectsModule], + controllers: [EdgesController], + providers: [EdgesService, EdgesRepository], + exports: [EdgesService, EdgesRepository], +}) +export class EdgesModule {} diff --git a/apps/server/src/edges/edges.repository.ts b/apps/server/src/edges/edges.repository.ts new file mode 100644 index 0000000..3a54cc5 --- /dev/null +++ b/apps/server/src/edges/edges.repository.ts @@ -0,0 +1,164 @@ +import { Injectable } from "@nestjs/common"; +import { Neo4jService } from "../neo4j/neo4j.service"; +import type { EdgeKind, EdgeProperties } from "./schemas/edge.schema"; + +export interface StoredEdge { + id: string; + projectId: string; + sourceNodeId: string; + targetNodeId: string; + kind: EdgeKind; + createdAt: string; + updatedAt: string; + properties: EdgeProperties; +} + +export interface EdgeFilter { + kind?: EdgeKind; + sourceNodeId?: string; + targetNodeId?: string; +} + +@Injectable() +export class EdgesRepository { + constructor(private readonly neo4j: Neo4jService) {} + + /** Creates edge and returns **actually persisted** edge (may return existing match on race). + * Idempotent on (source, target, kind) via `apoc.merge.relationship` -> no duplicate edge + * even if app-layer existsBetween races. Returns null if endpoint missing (race: deleted). */ + async create(edge: StoredEdge): Promise { + const result = await this.neo4j.run( + `MATCH (s:Node {id: $sourceId, projectId: $projectId}) + MATCH (t:Node {id: $targetId, projectId: $projectId}) + CALL apoc.merge.relationship(s, $kind, {}, $onCreateProps, t) YIELD rel + SET rel.createdAt = coalesce(rel.createdAt, datetime($createdAt)), + rel.updatedAt = coalesce(rel.updatedAt, datetime($updatedAt)) + RETURN rel AS r, type(rel) AS kind, s.id AS sourceId, t.id AS targetId`, + { + sourceId: edge.sourceNodeId, + targetId: edge.targetNodeId, + projectId: edge.projectId, + kind: edge.kind, + onCreateProps: { + id: edge.id, + projectId: edge.projectId, + kind: edge.kind, + properties: JSON.stringify(edge.properties), + }, + createdAt: edge.createdAt, + updatedAt: edge.updatedAt, + }, + ); + if (result.records.length === 0) return null; + return toStoredEdge(result.records[0]); + } + + async getById(projectId: string, id: string): Promise { + const result = await this.neo4j.run( + `MATCH (s:Node)-[r {id: $id, projectId: $projectId}]->(t:Node) + RETURN r, type(r) AS kind, s.id AS sourceId, t.id AS targetId`, + { id, projectId }, + ); + if (result.records.length === 0) return null; + return toStoredEdge(result.records[0]); + } + + async list(projectId: string, filter: EdgeFilter = {}): Promise { + const where: string[] = ["r.projectId = $projectId"]; + const params: Record = { projectId }; + if (filter.kind) { + where.push("type(r) = $kind"); + params.kind = filter.kind; + } + if (filter.sourceNodeId) { + where.push("s.id = $sourceId"); + params.sourceId = filter.sourceNodeId; + } + if (filter.targetNodeId) { + where.push("t.id = $targetId"); + params.targetId = filter.targetNodeId; + } + const result = await this.neo4j.run( + `MATCH (s:Node)-[r]->(t:Node) + WHERE ${where.join(" AND ")} + RETURN r, type(r) AS kind, s.id AS sourceId, t.id AS targetId`, + params, + ); + return result.records.map((rec) => toStoredEdge(rec)); + } + + async updateProperties( + projectId: string, + id: string, + properties: EdgeProperties, + updatedAt: string, + ): Promise { + const result = await this.neo4j.run( + `MATCH (s:Node)-[r {id: $id, projectId: $projectId}]->(t:Node) + SET r.properties = $properties, r.updatedAt = datetime($updatedAt) + RETURN r, type(r) AS kind, s.id AS sourceId, t.id AS targetId`, + { id, projectId, properties: JSON.stringify(properties), updatedAt }, + ); + if (result.records.length === 0) return null; + return toStoredEdge(result.records[0]); + } + + async delete(projectId: string, id: string): Promise { + const result = await this.neo4j.run( + `MATCH ()-[r {id: $id, projectId: $projectId}]->() + WITH r + DELETE r + RETURN 1 AS deleted`, + { id, projectId }, + ); + return result.records.length > 0; + } + + /** Query existence of source + target nodes (within projectId scope). */ + async nodesExist( + projectId: string, + sourceId: string, + targetId: string, + ): Promise<{ source: boolean; target: boolean }> { + const result = await this.neo4j.run( + `OPTIONAL MATCH (s:Node {id: $sourceId, projectId: $projectId}) + OPTIONAL MATCH (t:Node {id: $targetId, projectId: $projectId}) + RETURN s IS NOT NULL AS sourceExists, t IS NOT NULL AS targetExists`, + { sourceId, targetId, projectId }, + ); + const rec = result.records[0]; + return { + source: rec.get("sourceExists") as boolean, + target: rec.get("targetExists") as boolean, + }; + } + + /** Does same (source, target, kind) already exist? */ + async existsBetween( + projectId: string, + sourceId: string, + targetId: string, + kind: EdgeKind, + ): Promise { + const result = await this.neo4j.run( + `MATCH (s:Node {id: $sourceId, projectId: $projectId})-[r:\`${kind}\`]->(t:Node {id: $targetId, projectId: $projectId}) + RETURN r LIMIT 1`, + { sourceId, targetId, projectId }, + ); + return result.records.length > 0; + } +} + +function toStoredEdge(record: any): StoredEdge { + const r = record.get("r"); + return { + id: r.properties.id, + projectId: r.properties.projectId, + sourceNodeId: record.get("sourceId"), + targetNodeId: record.get("targetId"), + kind: record.get("kind") as EdgeKind, + createdAt: new Date(r.properties.createdAt).toISOString(), + updatedAt: new Date(r.properties.updatedAt).toISOString(), + properties: JSON.parse(r.properties.properties), + }; +} diff --git a/apps/server/src/edges/edges.service.ts b/apps/server/src/edges/edges.service.ts new file mode 100644 index 0000000..d0af953 --- /dev/null +++ b/apps/server/src/edges/edges.service.ts @@ -0,0 +1,290 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { randomUUID } from "node:crypto"; +import { EdgesRepository, type EdgeFilter, type StoredEdge } from "./edges.repository"; +import { NodesRepository } from "../nodes/nodes.repository"; +import { ProjectsRepository } from "../projects/projects.repository"; +import { RulesEngine } from "../rules/rules.engine"; +import type { EvaluationResult } from "../rules/types"; +import type { Edge, EdgeKind, EdgeProperties } from "./schemas/edge.schema"; +import type { EdgeWarning } from "./dto/edge-response.dto"; + +type CreateInput = Omit & { + id?: string; + createdAt?: string; + updatedAt?: string; +}; + +export interface UpdateInput { + properties?: EdgeProperties; + kind?: string; + sourceNodeId?: string; + targetNodeId?: string; +} + +export interface ValidateInput { + sourceNodeId: string; + targetNodeId: string; + kind: EdgeKind; +} + +@Injectable() +export class EdgesService { + constructor( + private readonly repo: EdgesRepository, + private readonly nodesRepo: NodesRepository, + private readonly projectsRepo: ProjectsRepository, + private readonly rulesEngine: RulesEngine, + ) {} + + async create(urlProjectId: string, input: CreateInput): Promise { + if (input.projectId !== urlProjectId) { + throw new BadRequestException({ + code: "ERR_PROJECT_MISMATCH", + message: "The projectId in the URL does not match the projectId in the body.", + }); + } + + if (!(await this.projectsRepo.exists(urlProjectId))) { + throw new NotFoundException({ + code: "ERR_PROJECT_NOT_FOUND", + message: `Project '${urlProjectId}' not found. Create a project first via POST /api/v1/projects.`, + }); + } + + if (input.sourceNodeId === input.targetNodeId) { + throw new BadRequestException({ + code: "ERR_EDGE_SELF_LOOP", + message: "A node cannot connect to itself (self-loops are forbidden).", + }); + } + + // Node existence + const exist = await this.repo.nodesExist(urlProjectId, input.sourceNodeId, input.targetNodeId); + if (!exist.source) { + throw new NotFoundException({ + code: "ERR_EDGE_SOURCE_NOT_FOUND", + message: `Source node '${input.sourceNodeId}' not found in this project.`, + }); + } + if (!exist.target) { + throw new NotFoundException({ + code: "ERR_EDGE_TARGET_NOT_FOUND", + message: `Target node '${input.targetNodeId}' not found in this project.`, + }); + } + + // ID conflict + if (input.id) { + const existing = await this.repo.getById(urlProjectId, input.id); + if (existing) { + throw new ConflictException({ + code: "ERR_ID_CONFLICT", + message: `Edge id '${input.id}' is already in use.`, + }); + } + } + + // Duplicate (same source/target/kind) + const dup = await this.repo.existsBetween(urlProjectId, input.sourceNodeId, input.targetNodeId, input.kind); + if (dup) { + throw new ConflictException({ + code: "ERR_EDGE_DUPLICATE", + message: `An '${input.kind}' edge already exists between this source/target.`, + }); + } + + // Rules Engine — full node fetch + evaluate + const sourceNode = await this.nodesRepo.getById(urlProjectId, input.sourceNodeId); + const targetNode = await this.nodesRepo.getById(urlProjectId, input.targetNodeId); + // Non-blocking warning (e.g. WARN_COND_001 empty-tab) — edge still created but + // attached to response for user display (not silently swallowed). + let warning: EdgeWarning | undefined; + if (sourceNode && targetNode) { + const evaluation = await this.rulesEngine.evaluate({ + projectId: urlProjectId, + sourceNode, + targetNode, + edgeKind: input.kind, + }); + if (!evaluation.allowed) { + throw new ConflictException({ + code: evaluation.code ?? "ERR_RULES_DENIED", + message: evaluation.message ?? "The connection violates the rules.", + ruleViolated: evaluation.ruleViolated, + suggestion: evaluation.suggestion, + docLink: evaluation.docLink, + }); + } + if (evaluation.severity === "warning" && evaluation.code) { + warning = { code: evaluation.code, message: evaluation.message ?? "", suggestion: evaluation.suggestion }; + } + } + + const id = input.id ?? randomUUID(); + const now = new Date().toISOString(); + const stored: StoredEdge = { + id, + projectId: urlProjectId, + sourceNodeId: input.sourceNodeId, + targetNodeId: input.targetNodeId, + kind: input.kind, + createdAt: input.createdAt ?? now, + updatedAt: input.updatedAt ?? now, + properties: input.properties, + }; + // repo.create idempotent (apoc.merge) + endpoint'leri atomik MATCH eder. + // null -> endpoint(s) deleted between check and create (race). + const persisted = await this.repo.create(stored); + if (!persisted) { + throw new NotFoundException({ + code: "ERR_EDGE_ENDPOINT_NOT_FOUND", + message: "Edge could not be created — the source/target node does not currently exist.", + }); + } + await this.projectsRepo.bumpRevision(urlProjectId); + return { ...this.toEdge(persisted), ...(warning ? { warning } : {}) }; + } + + async getById(projectId: string, id: string): Promise { + const stored = await this.repo.getById(projectId, id); + if (!stored) { + throw new NotFoundException({ + code: "ERR_EDGE_NOT_FOUND", + message: `Edge '${id}' not found.`, + }); + } + return this.toEdge(stored); + } + + async list(projectId: string, filter: EdgeFilter = {}): Promise { + const stored = await this.repo.list(projectId, filter); + return stored.map((s) => this.toEdge(s)); + } + + async update(projectId: string, id: string, input: UpdateInput): Promise { + if (input.kind !== undefined || input.sourceNodeId !== undefined || input.targetNodeId !== undefined) { + throw new BadRequestException({ + code: "ERR_EDGE_IMMUTABLE", + message: "An edge's kind / sourceNodeId / targetNodeId fields cannot be changed. Delete and recreate.", + }); + } + if (!input.properties) { + throw new BadRequestException({ + code: "ERR_PATCH_EMPTY", + message: "No field to change in the PATCH body (only properties can be updated).", + }); + } + const updatedAt = new Date().toISOString(); + const stored = await this.repo.updateProperties(projectId, id, input.properties, updatedAt); + if (!stored) { + throw new NotFoundException({ + code: "ERR_EDGE_NOT_FOUND", + message: `Edge '${id}' not found.`, + }); + } + await this.projectsRepo.bumpRevision(projectId); + return this.toEdge(stored); + } + + async delete(projectId: string, id: string): Promise { + const ok = await this.repo.delete(projectId, id); + if (!ok) { + throw new NotFoundException({ + code: "ERR_EDGE_NOT_FOUND", + message: `Edge '${id}' not found.`, + }); + } + await this.projectsRepo.bumpRevision(projectId); + } + + /** Pre-check: node existence + duplicate. Phase 2B'de Rules Engine eklenecek. */ + async validate(projectId: string, input: ValidateInput) { + if (input.sourceNodeId === input.targetNodeId) { + return { + isValid: false, + engineResult: { + code: "ERR_EDGE_SELF_LOOP", + message: "A node cannot connect to itself.", + suggestion: "Choose a different target node.", + }, + }; + } + const exist = await this.repo.nodesExist(projectId, input.sourceNodeId, input.targetNodeId); + if (!exist.source) { + return { + isValid: false, + engineResult: { + code: "ERR_EDGE_SOURCE_NOT_FOUND", + message: `Source node '${input.sourceNodeId}' yok.`, + }, + }; + } + if (!exist.target) { + return { + isValid: false, + engineResult: { + code: "ERR_EDGE_TARGET_NOT_FOUND", + message: `Target node '${input.targetNodeId}' yok.`, + }, + }; + } + const dup = await this.repo.existsBetween(projectId, input.sourceNodeId, input.targetNodeId, input.kind); + if (dup) { + return { + isValid: false, + engineResult: { + code: "ERR_EDGE_DUPLICATE", + message: `The same '${input.kind}' edge already exists between these nodes.`, + }, + }; + } + + // Rules Engine + const sourceNode = await this.nodesRepo.getById(projectId, input.sourceNodeId); + const targetNode = await this.nodesRepo.getById(projectId, input.targetNodeId); + if (sourceNode && targetNode) { + const evaluation = await this.rulesEngine.evaluate({ + projectId, + sourceNode, + targetNode, + edgeKind: input.kind, + }); + return this.toValidationResult(evaluation); + } + + return { isValid: true, engineResult: undefined }; + } + + private toValidationResult(e: EvaluationResult) { + if (e.allowed && !e.code) return { isValid: true, engineResult: undefined }; + return { + isValid: e.allowed, + severity: e.severity, + engineResult: { + code: e.code!, + ruleViolated: e.ruleViolated, + message: e.message!, + suggestion: e.suggestion, + docLink: e.docLink, + }, + }; + } + + private toEdge(s: StoredEdge): Edge { + return { + id: s.id, + projectId: s.projectId, + sourceNodeId: s.sourceNodeId, + targetNodeId: s.targetNodeId, + kind: s.kind, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + properties: s.properties, + }; + } +} diff --git a/apps/server/src/edges/schemas/edge.schema.spec.ts b/apps/server/src/edges/schemas/edge.schema.spec.ts new file mode 100644 index 0000000..55ff9b3 --- /dev/null +++ b/apps/server/src/edges/schemas/edge.schema.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { EdgeSchema, EdgePropertiesSchema, EDGE_KINDS } from "./edge.schema"; + +const validEdge = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + sourceNodeId: "550e8400-e29b-41d4-a716-446655440010", + targetNodeId: "550e8400-e29b-41d4-a716-446655440020", + kind: "CALLS" as const, + createdAt: "2026-05-22T10:30:00.000Z", + updatedAt: "2026-05-22T10:30:00.000Z", + properties: { IsAsync: false, Label: "fetchUser()" }, +}; + +describe("EdgeSchema", () => { + it("parses valid edge'i", () => { + const e = EdgeSchema.parse(validEdge); + expect(e.kind).toBe("CALLS"); + }); + + it("contains 16 EDGE_KINDS", () => { + expect(EDGE_KINDS).toHaveLength(16); + expect(EDGE_KINDS).toContain("CALLS"); + expect(EDGE_KINDS).toContain("THROWS"); + expect(EDGE_KINDS).toContain("ROUTES_TO"); + }); + + it("Bilinmeyen kind reddeder", () => { + expect(() => EdgeSchema.parse({ ...validEdge, kind: "FOO" })).toThrow(); + }); + + it("rejects non-UUID sourceNodeId", () => { + expect(() => EdgeSchema.parse({ ...validEdge, sourceNodeId: "abc" })).toThrow(); + }); + + it("properties.IsAsync zorunlu", () => { + expect(() => EdgeSchema.parse({ + ...validEdge, + properties: { Label: "x" } as any, + })).toThrow(); + }); + + it("rejects unknown properties field (strict)", () => { + expect(() => EdgeSchema.parse({ + ...validEdge, + properties: { IsAsync: false, Foo: "bar" } as any, + })).toThrow(); + }); +}); + +describe("EdgePropertiesSchema", () => { + it("Protocol opsiyonel + enum", () => { + expect(EdgePropertiesSchema.parse({ IsAsync: true, Protocol: "HTTP" }).Protocol).toBe("HTTP"); + expect(() => EdgePropertiesSchema.parse({ IsAsync: true, Protocol: "FTP" })).toThrow(); + }); + + it("RetryCount negatif olamaz", () => { + expect(() => EdgePropertiesSchema.parse({ IsAsync: true, RetryCount: -1 })).toThrow(); + }); +}); diff --git a/apps/server/src/edges/schemas/edge.schema.ts b/apps/server/src/edges/schemas/edge.schema.ts new file mode 100644 index 0000000..e87f783 --- /dev/null +++ b/apps/server/src/edges/schemas/edge.schema.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +export const EDGE_KINDS = [ + // 1. Call and communication + "CALLS", + "REQUESTS", + "PUBLISHES", + "SUBSCRIBES", + // 2. Data and schema + "USES", + "HAS", + "EXTENDS", + "IMPLEMENTS", + "RETURNS", + // 3. DB and infrastructure + "QUERIES", + "WRITES", + "CACHES_IN", + // 4. Architecture + "DEPENDS_ON", + "READS_CONFIG", + "THROWS", + "ROUTES_TO", +] as const; + +export type EdgeKind = (typeof EDGE_KINDS)[number]; + +export const EdgeKindSchema = z.enum(EDGE_KINDS); + +/* Plans/Edge Taxonomy: all edge kinds share the SAME property shape. + * No per-kind schema needed — discriminator + shared properties suffice. */ +export const EdgePropertiesSchema = z.object({ + Label: z.string().optional(), + IsAsync: z.boolean(), + Protocol: z.enum(["HTTP", "gRPC", "TCP", "WebSocket", "AMQP", "MQTT"]).optional(), + RetryCount: z.number().int().nonnegative().optional(), +}).strict(); + +export type EdgeProperties = z.infer; + +export const EdgeSchema = z.object({ + id: z.string().uuid(), + projectId: z.string().uuid(), + sourceNodeId: z.string().uuid(), + targetNodeId: z.string().uuid(), + kind: EdgeKindSchema, + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + properties: EdgePropertiesSchema, +}).strict(); + +export type Edge = z.infer; diff --git a/apps/server/src/embeddings/embeddings.factory.ts b/apps/server/src/embeddings/embeddings.factory.ts new file mode 100644 index 0000000..899c48e --- /dev/null +++ b/apps/server/src/embeddings/embeddings.factory.ts @@ -0,0 +1,21 @@ +import { OpenAIEmbeddings } from "@langchain/openai"; +import { env } from "../config/env"; + +/** local → always ready (offline ONNX). bedrock → key + base URL required. */ +export function embeddingsConfigured(): boolean { + if (env.EMBED_PROVIDER === "local") return true; + return !!(env.BEDROCK_API_KEY && env.BEDROCK_BASE_URL); +} + +/** bedrock-mantle OpenAI-compatible /embeddings. It does not currently offer a mantle embed model; + * ileride sunarsa EMBED_PROVIDER=bedrock + EMBED_MODEL ile devreye girer. */ +export function makeBedrockEmbedder(): OpenAIEmbeddings { + if (!env.BEDROCK_API_KEY || !env.BEDROCK_BASE_URL) { + throw new Error("BEDROCK_API_KEY ve BEDROCK_BASE_URL gerekli (embeddings=bedrock)."); + } + return new OpenAIEmbeddings({ + model: env.EMBED_MODEL, + apiKey: env.BEDROCK_API_KEY, + configuration: { baseURL: env.BEDROCK_BASE_URL }, + }); +} diff --git a/apps/server/src/embeddings/embeddings.module.ts b/apps/server/src/embeddings/embeddings.module.ts new file mode 100644 index 0000000..c1b3d32 --- /dev/null +++ b/apps/server/src/embeddings/embeddings.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { EmbeddingsService } from "./embeddings.service"; +import { EMBEDDINGS } from "./embeddings.types"; + +@Module({ + providers: [EmbeddingsService, { provide: EMBEDDINGS, useExisting: EmbeddingsService }], + exports: [EMBEDDINGS, EmbeddingsService], +}) +export class EmbeddingsModule {} diff --git a/apps/server/src/embeddings/embeddings.service.spec.ts b/apps/server/src/embeddings/embeddings.service.spec.ts new file mode 100644 index 0000000..04e7305 --- /dev/null +++ b/apps/server/src/embeddings/embeddings.service.spec.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { EmbeddingsService } from "./embeddings.service"; + +describe("EmbeddingsService", () => { + it("local provider'da isConfigured true", () => { + expect(new EmbeddingsService().isConfigured()).toBe(true); + }); + +it("embed converts local extractor output to number[]", async () => { + const svc = new EmbeddingsService(); + (svc as any).extractorPromise = Promise.resolve( + async () => ({ data: new Float32Array([0.1, 0.2, 0.3]) }), + ); + const vec = await svc.embed("x"); + expect(vec).toHaveLength(3); + expect(vec[0]).toBeCloseTo(0.1, 5); + }); + + it("embedBatch her metni embed eder", async () => { + const svc = new EmbeddingsService(); + (svc as any).extractorPromise = Promise.resolve( + async () => ({ data: new Float32Array([1, 0]) }), + ); + const vecs = await svc.embedBatch(["a", "b"]); + expect(vecs).toHaveLength(2); + expect(vecs[0]).toEqual([1, 0]); + }); +}); diff --git a/apps/server/src/embeddings/embeddings.service.ts b/apps/server/src/embeddings/embeddings.service.ts new file mode 100644 index 0000000..089840c --- /dev/null +++ b/apps/server/src/embeddings/embeddings.service.ts @@ -0,0 +1,45 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { env } from "../config/env"; +import { embeddingsConfigured, makeBedrockEmbedder } from "./embeddings.factory"; +import type { IEmbeddings } from "./embeddings.types"; + +/** @xenova/transformers feature-extraction pipeline a callable function. */ +type Extractor = (text: string, opts: { pooling: "mean"; normalize: boolean }) => Promise<{ data: Float32Array }>; + +@Injectable() +export class EmbeddingsService implements IEmbeddings { + private readonly logger = new Logger(EmbeddingsService.name); + private extractorPromise: Promise | null = null; + private bedrock: ReturnType | null = null; + + isConfigured(): boolean { + return embeddingsConfigured(); + } + +/** Lazy load the local ONNX model (~1sec on first call, then cache). */ + private localExtractor(): Promise { + if (!this.extractorPromise) { + this.extractorPromise = (async () => { +// dynamic import: @xenova/transformers ESM/CJS interop is safe in CJS build. + const { pipeline } = await import("@xenova/transformers"); + this.logger.log(`Loading local embedder: ${env.EMBED_MODEL}`); + return (await pipeline("feature-extraction", env.EMBED_MODEL)) as unknown as Extractor; + })(); + } + return this.extractorPromise; + } + + async embed(text: string): Promise { + if (env.EMBED_PROVIDER === "bedrock") { + this.bedrock ??= makeBedrockEmbedder(); + return this.bedrock.embedQuery(text); + } + const extractor = await this.localExtractor(); + const out = await extractor(text, { pooling: "mean", normalize: true }); + return Array.from(out.data); + } + + async embedBatch(texts: string[]): Promise { + return Promise.all(texts.map((t) => this.embed(t))); + } +} diff --git a/apps/server/src/embeddings/embeddings.types.ts b/apps/server/src/embeddings/embeddings.types.ts new file mode 100644 index 0000000..8a0d0ba --- /dev/null +++ b/apps/server/src/embeddings/embeddings.types.ts @@ -0,0 +1,9 @@ +/** DI token + interface — patterns service connects to this abstraction, + * testlerde fake embedder ile override edilir. */ +export const EMBEDDINGS = Symbol("EMBEDDINGS"); + +export interface IEmbeddings { + isConfigured(): boolean; + embed(text: string): Promise; + embedBatch(texts: string[]): Promise; +} diff --git a/apps/server/src/graph/dto/apply-graph-response.dto.ts b/apps/server/src/graph/dto/apply-graph-response.dto.ts new file mode 100644 index 0000000..6bf912e --- /dev/null +++ b/apps/server/src/graph/dto/apply-graph-response.dto.ts @@ -0,0 +1,34 @@ +import type { SuccessEnvelope } from "../../common/envelope"; + +export interface ApplyViolation { + /** tempId for node violation; edgeIndex for edge violation. */ + tempId?: string; + edgeIndex?: number; + source?: { tempId?: string; id?: string; type?: string }; + target?: { tempId?: string; id?: string; type?: string }; + attemptedEdgeType?: string; + code: string; + message: string; + suggestion?: string; + details?: Array<{ field: string; issue: string }>; +} + +export interface ApplyGraphSuccess { + success: true; + /** tempId -> persistent UUID mapping */ + idMap: Record; + nodeCount: number; + edgeCount: number; + /** Post-commit graph revision — client uses as baseRevision on next push. */ + graphRevision: number; +} + +export interface ApplyGraphFailure { + success: false; + transactionStatus: "ROLLED_BACK"; + message: string; + violations: ApplyViolation[]; +} + +export type ApplyGraphResult = ApplyGraphSuccess | ApplyGraphFailure; +export type ApplyGraphResponse = SuccessEnvelope; diff --git a/apps/server/src/graph/dto/apply-graph.dto.ts b/apps/server/src/graph/dto/apply-graph.dto.ts new file mode 100644 index 0000000..3e681f5 --- /dev/null +++ b/apps/server/src/graph/dto/apply-graph.dto.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; +import { createZodDto } from "nestjs-zod"; +import { EdgeKindSchema } from "../../edges/schemas/edge.schema"; + +// mutations.nodes[]: tempId + type + properties (kind-specific validation +// done in GraphService via NodeSchema). edges[]: tempId references. +const MutationNodeSchema = z.object({ + tempId: z.string().min(1), + type: z.string().min(1), + properties: z.record(z.unknown()), +}).strict(); + +// Edge endpoints in one of two forms: tempId (new node in batch) OR id (existing +// cloud node UUID). Exactly one per endpoint must be given — CLI push uses this +// bridge to connect new nodes to existing graph. +const MutationEdgeSchema = z.object({ + sourceTempId: z.string().min(1).optional(), + sourceId: z.string().uuid().optional(), + targetTempId: z.string().min(1).optional(), + targetId: z.string().uuid().optional(), + edgeType: EdgeKindSchema, + label: z.string().optional(), +}).strict() + .refine((e) => (e.sourceTempId ? 1 : 0) + (e.sourceId ? 1 : 0) === 1, { + message: "Exactly one of sourceTempId / sourceId is required.", + path: ["sourceTempId"], + }) + .refine((e) => (e.targetTempId ? 1 : 0) + (e.targetId ? 1 : 0) === 1, { + message: "Exactly one of targetTempId / targetId is required.", + path: ["targetTempId"], + }); + +export const ApplyGraphSchema = z.object({ + tabId: z.string().uuid().optional(), // home tab for generated nodes (default when omitted) + /** Conflict protection: graph revision client used to compute delta. When set + * and server graphRevision differs, returns 409 without writing anything. */ + baseRevision: z.number().int().nonnegative().optional(), + mutations: z.object({ + nodes: z.array(MutationNodeSchema), + edges: z.array(MutationEdgeSchema), + }).strict(), +}).strict(); + +export type ApplyGraphInput = z.infer; + +export class ApplyGraphDto extends createZodDto(ApplyGraphSchema) {} diff --git a/apps/server/src/graph/graph.controller.ts b/apps/server/src/graph/graph.controller.ts new file mode 100644 index 0000000..b002e14 --- /dev/null +++ b/apps/server/src/graph/graph.controller.ts @@ -0,0 +1,35 @@ +import { Body, Controller, Param, Post, HttpCode, UseGuards } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiParam, ApiResponse } from "@nestjs/swagger"; +import { ProjectAccessGuard } from "../auth/project-access.guard"; +import { GraphService } from "./graph.service"; +import { ApplyGraphDto } from "./dto/apply-graph.dto"; +import { ok } from "../common/envelope"; +import type { ApplyGraphResponse } from "./dto/apply-graph-response.dto"; + +@ApiTags("Graph") +@UseGuards(ProjectAccessGuard) +@Controller("projects/:projectId/graph") +export class GraphController { + constructor(private readonly service: GraphService) {} + + @Post("apply") + @HttpCode(200) + @ApiOperation({ + summary: "Apply the architecture graph in bulk (AI batch)", + description: + "Processes multiple nodes + edges in a **single atomic transaction**. Used by the AI agent or the frontend for bulk-save.\n\n" + + "Each node is validated with Zod, each edge with the Rules Engine + an in-batch circular-dependency check is performed. " + + "**On any violation the entire batch is rolled back** (ROLLED_BACK) and `violations[]` + `suggestion`s are returned — the AI reads these and self-corrects.\n\n" + + "Edges reference nodes by `tempId`; on success `idMap { tempId → permanent UUID }` is returned. Positions are assigned server-side via auto-grid.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "`data.success=true` → idMap + nodeCount + edgeCount. `data.success=false` → transactionStatus ROLLED_BACK + violations[]." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async apply( + @Param("projectId") projectId: string, + @Body() body: ApplyGraphDto, + ): Promise { + const result = await this.service.apply(projectId, body as any); + return ok(result); + } +} diff --git a/apps/server/src/graph/graph.module.ts b/apps/server/src/graph/graph.module.ts new file mode 100644 index 0000000..58385fc --- /dev/null +++ b/apps/server/src/graph/graph.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { GraphController } from "./graph.controller"; +import { GraphService } from "./graph.service"; +import { ProjectsModule } from "../projects/projects.module"; +import { NodesModule } from "../nodes/nodes.module"; +import { RulesModule } from "../rules/rules.module"; +import { TabsModule } from "../tabs/tabs.module"; + +@Module({ + imports: [ProjectsModule, NodesModule, RulesModule, TabsModule], + controllers: [GraphController], + providers: [GraphService], + exports: [GraphService], +}) +export class GraphModule {} diff --git a/apps/server/src/graph/graph.service.spec.ts b/apps/server/src/graph/graph.service.spec.ts new file mode 100644 index 0000000..58654d4 --- /dev/null +++ b/apps/server/src/graph/graph.service.spec.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ConflictException, NotFoundException } from "@nestjs/common"; +import { GraphService } from "./graph.service"; + +function makeDeps(opts: { + projectExists?: boolean; + evaluateResult?: any; + graphRevision?: number; + /** id -> stored node; nodesRepo.getById returns from this map. */ + existingNodes?: Record; + /** revision query in commit tx will return this rev (undefined = no record). */ + txRevision?: number; +} = {}) { + const txRun = vi.fn(async (cypher: string) => { + if (cypher.includes("graphRevision")) { + return opts.txRevision === undefined + ? { records: [] } + : { records: [{ get: () => opts.txRevision }] }; + } + return { records: [] }; + }); + const neo4j = { write: vi.fn(async (work: any) => work({ run: txRun })) }; + const projectsRepo = { + exists: vi.fn(async () => opts.projectExists ?? true), + getGraphRevision: vi.fn(async () => opts.graphRevision ?? 0), + }; + const nodesRepo = { + findByName: vi.fn(async () => null), + getById: vi.fn(async (_projectId: string, id: string) => opts.existingNodes?.[id] ?? null), + findNameKey: vi.fn((kind: string) => + kind === "Table" ? "TableName" : kind === "Service" ? "ServiceName" : kind === "Controller" ? "ControllerName" : kind === "Repository" ? "RepositoryName" : "Name", + ), + }; + const rulesEngine = { evaluate: vi.fn(async () => opts.evaluateResult ?? { allowed: true }) }; + const tabs = { ensureDefault: vi.fn(async () => ({ id: "550e8400-e29b-41d4-a716-4466554400aa" })) }; + return { neo4j, projectsRepo, nodesRepo, rulesEngine, tabs, txRun }; +} + +const projectId = "550e8400-e29b-41d4-a716-446655440001"; + +const tableProps = (name: string) => ({ + TableName: name, Description: "d", + Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }], +}); +const svcProps = (name: string) => ({ + ServiceName: name, Description: "d", + Methods: [{ MethodName: "x", Parameters: [], ReturnType: "void" }], IsTransactionScoped: false, +}); + +describe("GraphService.apply", () => { + let deps: ReturnType; + let service: GraphService; + + beforeEach(() => { + deps = makeDeps(); + service = new GraphService(deps.neo4j as any, deps.projectsRepo as any, deps.nodesRepo as any, deps.rulesEngine as any, deps.tabs as any); + }); + + it("throws NotFoundException when project missing", async () => { + deps = makeDeps({ projectExists: false }); + service = new GraphService(deps.neo4j as any, deps.projectsRepo as any, deps.nodesRepo as any, deps.rulesEngine as any, deps.tabs as any); + await expect(service.apply(projectId, { mutations: { nodes: [], edges: [] } } as any)) + .rejects.toBeInstanceOf(NotFoundException); + }); + + it("valid batch -> success + idMap + commit", async () => { + const result = await service.apply(projectId, { + mutations: { + nodes: [ + { tempId: "t_svc", type: "Service", properties: svcProps("OrderSvc") }, + { tempId: "t_repo", type: "Repository", properties: { RepositoryName: "OrderRepo", Description: "d", EntityReference: "Order", CustomQueries: [] } }, + ], + edges: [{ sourceTempId: "t_svc", targetTempId: "t_repo", edgeType: "CALLS" }], + }, + } as any); + expect(result.success).toBe(true); + if (result.success) { + expect(Object.keys(result.idMap)).toEqual(["t_svc", "t_repo"]); + expect(result.nodeCount).toBe(2); + expect(result.edgeCount).toBe(1); + } + expect(deps.neo4j.write).toHaveBeenCalledOnce(); + }); + + it("schema violation -> ROLLED_BACK + no commit", async () => { + const result = await service.apply(projectId, { + mutations: { nodes: [{ tempId: "t1", type: "Table", properties: { TableName: "x" } }], edges: [] }, + } as any); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.transactionStatus).toBe("ROLLED_BACK"); + expect(result.violations[0].code).toBe("ERR_SCHEMA_INVALID"); + } + expect(deps.neo4j.write).not.toHaveBeenCalled(); + }); + + it("Rules violation (ERR_002) -> ROLLED_BACK", async () => { + deps = makeDeps({ evaluateResult: { allowed: false, code: "ERR_002", message: "Controller cannot write to DB", suggestion: "Add Repository" } }); + service = new GraphService(deps.neo4j as any, deps.projectsRepo as any, deps.nodesRepo as any, deps.rulesEngine as any, deps.tabs as any); + const result = await service.apply(projectId, { + mutations: { + nodes: [ + { tempId: "t_ctrl", type: "Controller", properties: { ControllerName: "C", Description: "d", BaseRoute: "/x", Endpoints: [{ HttpMethod: "POST", Route: "/", RequiresAuth: false }] } }, + { tempId: "t_tbl", type: "Table", properties: tableProps("orders") }, + ], + edges: [{ sourceTempId: "t_ctrl", targetTempId: "t_tbl", edgeType: "WRITES" }], + }, + } as any); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.violations[0].code).toBe("ERR_002"); + expect(result.violations[0].suggestion).toContain("Repository"); + } + expect(deps.neo4j.write).not.toHaveBeenCalled(); + }); + + it("in-batch circular CALLS -> ERR_COND_001", async () => { + const result = await service.apply(projectId, { + mutations: { + nodes: [ + { tempId: "a", type: "Service", properties: svcProps("A") }, + { tempId: "b", type: "Service", properties: svcProps("B") }, + ], + edges: [ + { sourceTempId: "a", targetTempId: "b", edgeType: "CALLS" }, + { sourceTempId: "b", targetTempId: "a", edgeType: "CALLS" }, + ], + }, + } as any); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.violations.some((v) => v.code === "ERR_COND_001")).toBe(true); + } + }); + + it("duplicate tempId -> ERR_DUPLICATE_TEMP_ID", async () => { + const result = await service.apply(projectId, { + mutations: { + nodes: [ + { tempId: "dup", type: "Service", properties: svcProps("A") }, + { tempId: "dup", type: "Service", properties: svcProps("B") }, + ], + edges: [], + }, + } as any); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.violations.some((v) => v.code === "ERR_DUPLICATE_TEMP_ID")).toBe(true); + } + }); + + it("invalid tempId reference -> ERR_EDGE_TEMP_NOT_FOUND", async () => { + const result = await service.apply(projectId, { + mutations: { + nodes: [{ tempId: "a", type: "Service", properties: svcProps("A") }], + edges: [{ sourceTempId: "a", targetTempId: "ghost", edgeType: "CALLS" }], + }, + } as any); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.violations.some((v) => v.code === "ERR_EDGE_TEMP_NOT_FOUND")).toBe(true); + } + }); +}); + +const cloudNodeId = "550e8400-e29b-41d4-a716-446655440099"; +const cloudRepo = { + id: cloudNodeId, + type: "Repository", + projectId, + positionX: 0, + positionY: 0, + homeTabId: "tab", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + version: 1, + properties: { RepositoryName: "OrderRepo", Description: "d", EntityReference: "Order", CustomQueries: [] }, +}; + +describe("GraphService.apply — edge to existing node (upsert bridge)", () => { + it("new node -> existing cloud node edge: loaded from DB, passed to Rules Engine, committed", async () => { + const deps = makeDeps({ existingNodes: { [cloudNodeId]: cloudRepo }, txRevision: 1 }); + const service = new GraphService(deps.neo4j as any, deps.projectsRepo as any, deps.nodesRepo as any, deps.rulesEngine as any, deps.tabs as any); + const result = await service.apply(projectId, { + mutations: { + nodes: [{ tempId: "t_svc", type: "Service", properties: svcProps("OrderSvc") }], + edges: [{ sourceTempId: "t_svc", targetId: cloudNodeId, edgeType: "CALLS" }], + }, + } as any); + expect(result.success).toBe(true); + if (result.success) expect(result.graphRevision).toBe(1); + expect(deps.nodesRepo.getById).toHaveBeenCalledWith(projectId, cloudNodeId); + expect(deps.rulesEngine.evaluate).toHaveBeenCalledWith( + expect.objectContaining({ targetNode: cloudRepo }), + ); + expect(deps.neo4j.write).toHaveBeenCalledOnce(); + }); + + it("when existing node id not found -> ERR_EDGE_NODE_NOT_FOUND + rollback", async () => { + const deps = makeDeps(); // existingNodes empty + const service = new GraphService(deps.neo4j as any, deps.projectsRepo as any, deps.nodesRepo as any, deps.rulesEngine as any, deps.tabs as any); + const result = await service.apply(projectId, { + mutations: { + nodes: [{ tempId: "t_svc", type: "Service", properties: svcProps("OrderSvc") }], + edges: [{ sourceTempId: "t_svc", targetId: cloudNodeId, edgeType: "CALLS" }], + }, + } as any); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.violations.some((v) => v.code === "ERR_EDGE_NODE_NOT_FOUND")).toBe(true); + } + expect(deps.neo4j.write).not.toHaveBeenCalled(); + }); + + it("edge merge cypher is idempotent (apoc.merge.relationship)", async () => { + const deps = makeDeps({ existingNodes: { [cloudNodeId]: cloudRepo }, txRevision: 1 }); + const service = new GraphService(deps.neo4j as any, deps.projectsRepo as any, deps.nodesRepo as any, deps.rulesEngine as any, deps.tabs as any); + await service.apply(projectId, { + mutations: { + nodes: [{ tempId: "t_svc", type: "Service", properties: svcProps("OrderSvc") }], + edges: [{ sourceTempId: "t_svc", targetId: cloudNodeId, edgeType: "CALLS" }], + }, + } as any); + const edgeCypher = deps.txRun.mock.calls.map((c) => c[0]).find((c: string) => c.includes("relationship")); + expect(edgeCypher).toContain("apoc.merge.relationship"); + }); +}); + +describe("GraphService.apply — graph revision conflict", () => { + it("stale baseRevision -> 409 ERR_GRAPH_REVISION_CONFLICT without writing", async () => { + const deps = makeDeps({ graphRevision: 5 }); + const service = new GraphService(deps.neo4j as any, deps.projectsRepo as any, deps.nodesRepo as any, deps.rulesEngine as any, deps.tabs as any); + const err = await service + .apply(projectId, { + baseRevision: 3, + mutations: { nodes: [{ tempId: "t_svc", type: "Service", properties: svcProps("OrderSvc") }], edges: [] }, + } as any) + .catch((e) => e); + expect(err).toBeInstanceOf(ConflictException); + expect(err.getResponse()).toMatchObject({ code: "ERR_GRAPH_REVISION_CONFLICT", currentRevision: 5 }); + expect(deps.neo4j.write).not.toHaveBeenCalled(); + }); + + it("current baseRevision -> commit + new graphRevision", async () => { + const deps = makeDeps({ graphRevision: 3, txRevision: 4 }); + const service = new GraphService(deps.neo4j as any, deps.projectsRepo as any, deps.nodesRepo as any, deps.rulesEngine as any, deps.tabs as any); + const result = await service.apply(projectId, { + baseRevision: 3, + mutations: { nodes: [{ tempId: "t_svc", type: "Service", properties: svcProps("OrderSvc") }], edges: [] }, + } as any); + expect(result.success).toBe(true); + if (result.success) expect(result.graphRevision).toBe(4); + }); + + it("revision stale inside commit transaction -> rollback + 409", async () => { + // Pre-check passes (graphRevision=3) but atomic check inside tx returns 0 rows. + const deps = makeDeps({ graphRevision: 3, txRevision: undefined }); + const service = new GraphService(deps.neo4j as any, deps.projectsRepo as any, deps.nodesRepo as any, deps.rulesEngine as any, deps.tabs as any); + await expect( + service.apply(projectId, { + baseRevision: 3, + mutations: { nodes: [{ tempId: "t_svc", type: "Service", properties: svcProps("OrderSvc") }], edges: [] }, + } as any), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it("empty mutation no-op: no bump, returns current revision", async () => { + const deps = makeDeps({ graphRevision: 7 }); + const service = new GraphService(deps.neo4j as any, deps.projectsRepo as any, deps.nodesRepo as any, deps.rulesEngine as any, deps.tabs as any); + const result = await service.apply(projectId, { mutations: { nodes: [], edges: [] } } as any); + expect(result.success).toBe(true); + if (result.success) expect(result.graphRevision).toBe(7); + expect(deps.neo4j.write).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/graph/graph.service.ts b/apps/server/src/graph/graph.service.ts new file mode 100644 index 0000000..020ffaa --- /dev/null +++ b/apps/server/src/graph/graph.service.ts @@ -0,0 +1,390 @@ +import { ConflictException, Injectable, NotFoundException } from "@nestjs/common"; +import { randomUUID } from "node:crypto"; +import { Neo4jService } from "../neo4j/neo4j.service"; +import { ProjectsRepository } from "../projects/projects.repository"; +import { NodesRepository, type StoredNode } from "../nodes/nodes.repository"; +import { RulesEngine } from "../rules/rules.engine"; +import { TabsService } from "../tabs/tabs.service"; +import { NodeSchema, type NodeKind } from "../nodes/schemas"; +import { assertNoPlaintextSecret } from "../nodes/secret-redaction"; +import type { EdgeKind } from "../edges/schemas/edge.schema"; +import type { ApplyGraphInput } from "./dto/apply-graph.dto"; +import type { + ApplyGraphResult, + ApplyViolation, +} from "./dto/apply-graph-response.dto"; + +const GRID_COLS = 5; +const GRID_X = 280; +const GRID_Y = 180; + +@Injectable() +export class GraphService { + constructor( + private readonly neo4j: Neo4jService, + private readonly projectsRepo: ProjectsRepository, + private readonly nodesRepo: NodesRepository, + private readonly rulesEngine: RulesEngine, + private readonly tabs: TabsService, + ) {} + + async apply(projectId: string, input: ApplyGraphInput): Promise { + if (!(await this.projectsRepo.exists(projectId))) { + throw new NotFoundException({ + code: "ERR_PROJECT_NOT_FOUND", + message: `Project '${projectId}' not found. Create a project first via POST /api/v1/projects.`, + }); + } + + // Conflict pre-check: if revision client used for delta is stale, 409 without writing. + // (Actual atomic guarantee is repeated in commit transaction.) + if (input.baseRevision !== undefined) { + const current = await this.projectsRepo.getGraphRevision(projectId); + if (current !== input.baseRevision) { + throw this.revisionConflict(input.baseRevision, current); + } + } + + const { nodes, edges } = input.mutations; + // Home tab for generated nodes: given tabId or project default tab. + const homeTabId = input.tabId ?? (await this.tabs.ensureDefault(projectId)).id; + const violations: ApplyViolation[] = []; + const idMap: Record = {}; + const nodeMap = new Map(); + const now = new Date().toISOString(); + + // ── 1. tempId uniqueness ─────────────────────────────────────── + const seenTempIds = new Set(); + for (const node of nodes) { + if (seenTempIds.has(node.tempId)) { + violations.push({ + tempId: node.tempId, + code: "ERR_DUPLICATE_TEMP_ID", + message: `tempId '${node.tempId}' was used more than once.`, + }); + } + seenTempIds.add(node.tempId); + } + + // ── 2. node schema validation + grid position ──────────────────────── + nodes.forEach((node, i) => { + const id = randomUUID(); + const candidate = { + id, + type: node.type, + projectId, + position: gridPosition(i), + createdAt: now, + updatedAt: now, + properties: node.properties, + }; + const result = NodeSchema.safeParse(candidate); + if (!result.success) { + violations.push({ + tempId: node.tempId, + code: "ERR_SCHEMA_INVALID", + message: `The '${node.type}' node failed schema validation.`, + details: result.error.issues.map((iss) => ({ + field: iss.path.join("."), + issue: iss.message, + })), + }); + return; + } + // Security: this batch path bypasses NodesService -> apply secret guard here + // too (otherwise IsSecret=true + plaintext DefaultValue writes at-rest plaintext). + try { + assertNoPlaintextSecret(node.type, node.properties as Record); + } catch { + violations.push({ + tempId: node.tempId, + code: "ERR_SECRET_PLAINTEXT", + message: "When IsSecret=true, DefaultValue (plain-text secret) cannot be stored; use a secret manager/env binding.", + }); + return; + } + idMap[node.tempId] = id; + nodeMap.set(node.tempId, { + id, + type: node.type as NodeKind, + projectId, + positionX: candidate.position.x, + positionY: candidate.position.y, + homeTabId, + createdAt: now, + updatedAt: now, + version: 1, + properties: node.properties as Record, + }); + }); + + // ── 3. name uniqueness (in-batch + DB) ──────────────────────── + await this.checkNames(projectId, nodeMap, violations); + + // ── 4. resolve existing cloud nodes (edge endpoints sourceId/targetId) ── + const existingNodes = new Map(); + const referencedIds = new Set(); + for (const e of edges) { + if (e.sourceId) referencedIds.add(e.sourceId); + if (e.targetId) referencedIds.add(e.targetId); + } + for (const id of referencedIds) { + const stored = await this.nodesRepo.getById(projectId, id); + if (stored) existingNodes.set(id, stored); + } + + // ── 5. edge validation + Rules Engine ────────────────────────────── + const resolveEndpoint = (tempId?: string, cloudId?: string) => + tempId ? nodeMap.get(tempId) : existingNodes.get(cloudId!); + + for (let i = 0; i < edges.length; i++) { + const edge = edges[i]; + const src = resolveEndpoint(edge.sourceTempId, edge.sourceId); + const tgt = resolveEndpoint(edge.targetTempId, edge.targetId); + if (!src) { + violations.push( + edge.sourceTempId + ? { edgeIndex: i, code: "ERR_EDGE_TEMP_NOT_FOUND", message: `sourceTempId '${edge.sourceTempId}' not found in the batch.` } + : { edgeIndex: i, code: "ERR_EDGE_NODE_NOT_FOUND", message: `Source node '${edge.sourceId}' not found in this project.` }, + ); + continue; + } + if (!tgt) { + violations.push( + edge.targetTempId + ? { edgeIndex: i, code: "ERR_EDGE_TEMP_NOT_FOUND", message: `targetTempId '${edge.targetTempId}' not found in the batch.` } + : { edgeIndex: i, code: "ERR_EDGE_NODE_NOT_FOUND", message: `Target node '${edge.targetId}' not found in this project.` }, + ); + continue; + } + if (src.id === tgt.id) { + violations.push({ edgeIndex: i, code: "ERR_EDGE_SELF_LOOP", message: "A node cannot connect to itself." }); + continue; + } + const evaluation = await this.rulesEngine.evaluate({ + projectId, + sourceNode: src, + targetNode: tgt, + edgeKind: edge.edgeType, + }); + if (!evaluation.allowed) { + violations.push({ + edgeIndex: i, + source: { tempId: edge.sourceTempId, id: edge.sourceId, type: src.type }, + target: { tempId: edge.targetTempId, id: edge.targetId, type: tgt.type }, + attemptedEdgeType: edge.edgeType, + code: evaluation.code ?? "ERR_RULES_DENIED", + message: evaluation.message ?? "The connection violates the rules.", + suggestion: evaluation.suggestion, + }); + } + } + + // ── 6. in-batch circular dependency (CALLS) ────────────────────── + const cycle = detectBatchCycle(edges, nodeMap); + if (cycle) { + violations.push({ + code: "ERR_COND_001", + message: `Circular dependency within the batch: ${cycle.join(" → ")}. This leads to an infinite loop (Stack Overflow).`, + suggestion: "Break the cycle event-driven with an Orchestrator (Saga) or a MessageQueue.", + }); + } + + // ── 7. rollback on violations (no commit) ────────────────────── + if (violations.length > 0) { + return { + success: false, + transactionStatus: "ROLLED_BACK", + message: "The architecture graph has violations blocked by the Rules Engine. No changes were saved.", + violations, + }; + } + + // Empty mutation = no-op: return current revision without bump (idempotent). + if (nodes.length === 0 && edges.length === 0) { + const graphRevision = await this.projectsRepo.getGraphRevision(projectId); + return { success: true, idMap, nodeCount: 0, edgeCount: 0, graphRevision }; + } + + // ── 8. atomic commit (single transaction, revision check + bump included) ── + const graphRevision = await this.commit(projectId, nodes, edges, nodeMap, idMap, now, input.baseRevision); + + return { + success: true, + idMap, + nodeCount: nodes.length, + edgeCount: edges.length, + graphRevision, + }; + } + + private revisionConflict(baseRevision: number, currentRevision: number): ConflictException { + return new ConflictException({ + code: "ERR_GRAPH_REVISION_CONFLICT", + message: `The graph was modified since revision ${baseRevision} (current: ${currentRevision}). Pull the latest graph, recompute the delta and retry.`, + currentRevision, + }); + } + + private async checkNames( + projectId: string, + nodeMap: Map, + violations: ApplyViolation[], + ): Promise { + const batchNames = new Set(); + for (const [tempId, node] of nodeMap.entries()) { + const nameKey = this.nodesRepo.findNameKey(node.type); + const name = (node.properties as Record)[nameKey] as string | undefined; + if (!name) continue; + // in-batch + if (batchNames.has(name)) { + violations.push({ tempId, code: "ERR_NAME_DUPLICATE", message: `The name '${name}' is used by more than one node in the batch.` }); + continue; + } + batchNames.add(name); + // DB + const collision = await this.nodesRepo.findByName(projectId, name); + if (collision) { + violations.push({ tempId, code: "ERR_NAME_DUPLICATE", message: `The name '${name}' is already in use in this project.` }); + } + } + } + + /** Single transaction: revision check (when baseRevision given) + bump, + * node create, edge merge. Returns graphRevision after commit. */ + private async commit( + projectId: string, + nodes: ApplyGraphInput["mutations"]["nodes"], + edges: ApplyGraphInput["mutations"]["edges"], + nodeMap: Map, + idMap: Record, + now: string, + baseRevision?: number, + ): Promise { + const nodeParams = nodes.map((n) => { + const stored = nodeMap.get(n.tempId)!; + return { + kind: stored.type, + props: { + id: stored.id, + projectId, + positionX: stored.positionX, + positionY: stored.positionY, + homeTabId: stored.homeTabId, + version: 1, // consistent with HTTP create path (optimistic concurrency) + properties: JSON.stringify(stored.properties), + }, + createdAt: now, + updatedAt: now, + }; + }); + + const edgeParams = edges.map((e) => ({ + sourceId: e.sourceTempId ? idMap[e.sourceTempId] : e.sourceId!, + targetId: e.targetTempId ? idMap[e.targetTempId] : e.targetId!, + kind: e.edgeType, + props: { + id: randomUUID(), + projectId, + kind: e.edgeType, + properties: JSON.stringify({ IsAsync: false, ...(e.label ? { Label: e.label } : {}) }), + }, + createdAt: now, + updatedAt: now, + })); + + return this.neo4j.write(async (tx) => { + // Atomic revision check + bump: when baseRevision given and another write + // slipped in before this transaction, returns 0 rows -> rollback with 409. + const revResult = await tx.run( + `MATCH (p:Project {id: $projectId}) + WITH p, coalesce(p.graphRevision, 0) AS rev + WHERE $baseRevision IS NULL OR rev = $baseRevision + SET p.graphRevision = rev + 1 + RETURN p.graphRevision AS rev`, + { projectId, baseRevision: baseRevision ?? null }, + ); + if (baseRevision !== undefined && revResult.records.length === 0) { + const current = await this.projectsRepo.getGraphRevision(projectId); + throw this.revisionConflict(baseRevision, current); + } + + await tx.run( + `UNWIND $nodes AS nd + CALL apoc.create.node(['Node', nd.kind], nd.props) YIELD node + SET node.createdAt = datetime(nd.createdAt), node.updatedAt = datetime(nd.updatedAt) + RETURN count(node)`, + { nodes: nodeParams }, + ); + if (edgeParams.length > 0) { + // apoc.merge.relationship -> does not create same (source, target, kind, projectId) + // edge twice (idempotent push). props applied only on create. + await tx.run( + `UNWIND $edges AS ed + MATCH (s:Node {id: ed.sourceId}), (t:Node {id: ed.targetId}) + CALL apoc.merge.relationship(s, ed.kind, {projectId: ed.props.projectId}, ed.props, t, {}) YIELD rel + SET rel.createdAt = coalesce(rel.createdAt, datetime(ed.createdAt)), + rel.updatedAt = coalesce(rel.updatedAt, datetime(ed.updatedAt)) + RETURN count(rel)`, + { edges: edgeParams }, + ); + } + + const rev = revResult.records[0]?.get("rev"); + return rev == null ? 0 : Number(rev); + }); + } +} + +function gridPosition(index: number): { x: number; y: number } { + return { x: (index % GRID_COLS) * GRID_X, y: Math.floor(index / GRID_COLS) * GRID_Y }; +} + +/** Is there a cycle in CALLS edges within the batch? Returns the chain if so. */ +function detectBatchCycle( + edges: ApplyGraphInput["mutations"]["edges"], + nodeMap: Map, +): string[] | null { + const adj = new Map(); + for (const e of edges) { + if (e.edgeType !== "CALLS") continue; + // Only in-batch (tempId<->tempId) CALLS chains — edges to existing cloud nodes + // merge into DB graph, excluded from batch cycle analysis. + if (!e.sourceTempId || !e.targetTempId) continue; + if (!nodeMap.has(e.sourceTempId) || !nodeMap.has(e.targetTempId)) continue; + if (!adj.has(e.sourceTempId)) adj.set(e.sourceTempId, []); + adj.get(e.sourceTempId)!.push(e.targetTempId); + } + + const WHITE = 0, GRAY = 1, BLACK = 2; + const color = new Map(); + const stack: string[] = []; + + const dfs = (node: string): string[] | null => { + color.set(node, GRAY); + stack.push(node); + for (const next of adj.get(node) ?? []) { + const c = color.get(next) ?? WHITE; + if (c === GRAY) { + // cycle — extract chain from stack + const idx = stack.indexOf(next); + return [...stack.slice(idx), next]; + } + if (c === WHITE) { + const found = dfs(next); + if (found) return found; + } + } + stack.pop(); + color.set(node, BLACK); + return null; + }; + + for (const node of adj.keys()) { + if ((color.get(node) ?? WHITE) === WHITE) { + const found = dfs(node); + if (found) return found; + } + } + return null; +} diff --git a/apps/server/src/health/health.controller.spec.ts b/apps/server/src/health/health.controller.spec.ts new file mode 100644 index 0000000..bc542a1 --- /dev/null +++ b/apps/server/src/health/health.controller.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { ServiceUnavailableException } from "@nestjs/common"; +import { HealthController } from "./health.controller"; + +const make = (ping: () => Promise) => new HealthController({ ping } as never); + +describe("HealthController", () => { + it("liveness: returns status ok (does not touch DB)", () => { + const controller = make(async () => {}); + const result = controller.check(); + expect(result.success).toBe(true); + expect(result.data.status).toBe("ok"); + expect(typeof result.data.uptime).toBe("number"); + }); + + it("readiness: DB up → status ready (200)", async () => { + const controller = make(async () => {}); + const result = await controller.ready(); + expect(result.success).toBe(true); + expect(result.data.status).toBe("ready"); + }); + + it("readiness: DB down → ServiceUnavailableException (503) ERR_NOT_READY", async () => { + const controller = make(async () => { throw new Error("neo4j down"); }); + let caught: ServiceUnavailableException | null = null; + try { await controller.ready(); } catch (e) { caught = e as ServiceUnavailableException; } + expect(caught).toBeInstanceOf(ServiceUnavailableException); + expect((caught!.getResponse() as { code: string }).code).toBe("ERR_NOT_READY"); + }); +}); diff --git a/apps/server/src/health/health.controller.ts b/apps/server/src/health/health.controller.ts new file mode 100644 index 0000000..0cf1a66 --- /dev/null +++ b/apps/server/src/health/health.controller.ts @@ -0,0 +1,63 @@ +import { Controller, Get, ServiceUnavailableException } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; +import { SkipThrottle } from "@nestjs/throttler"; +import { ok } from "../common/envelope"; +import { Public } from "../auth/public.decorator"; +import { Neo4jService } from "../neo4j/neo4j.service"; + +@ApiTags("Health") +@Controller("health") +// Liveness/readiness probes are called frequently — exempt from rate limiting. +@SkipThrottle() +export class HealthController { + constructor(private readonly neo4j: Neo4jService) {} + + /** LIVENESS — is the process up. Does NOT touch the DB (avoid killing the process during Neo4j flaps). */ + @Public() + @Get() + @ApiOperation({ + summary: "Liveness — is the service up", + description: "The process is up. `{ status: 'ok', uptime }`. Does not touch the DB (liveness probe).", + }) + @ApiResponse({ status: 200, description: "`data: { status: 'ok', uptime: }`." }) + check() { + return ok({ status: "ok", uptime: process.uptime() }); + } + + /** READINESS — ready to accept traffic. Connects to Neo4j with a real RETURN 1; + * returns 503 if the DB is unreachable (reverse proxy/orchestrator should stop traffic but NOT kill the process). */ + @Public() + @Get("ready") + @ApiOperation({ + summary: "Readiness — are the dependencies (Neo4j) ready", + description: "Real connection check via Neo4j 'RETURN 1'. 200 if ready, 503 if the DB is down.", + }) + @ApiResponse({ status: 200, description: "`data: { status: 'ready' }`." }) + @ApiResponse({ status: 503, description: "`ERR_NOT_READY` — Neo4j is unreachable." }) + async ready() { + try { + // Short timeout: during network partition (packets drop, not refuse) ping would + // inherit driver timeouts (connectionTimeout 30s / acquisition 60s), leaving the + // probe hanging 30–60s and holding a pool slot. Probe must fail fast (≤2s). + await this.pingWithTimeout(2_000); + return ok({ status: "ready" }); + } catch { + // {code,message} → InternalFilter wraps in envelope (existing exception pattern). + throw new ServiceUnavailableException({ code: "ERR_NOT_READY", message: "Neo4j is unreachable." }); + } + } + + /** Race ping against a time limit — probe returns 503 quickly when DB is unreachable. + * (If the race is lost, the underlying query still closes eventually via connectionTimeout.) */ + private async pingWithTimeout(ms: number): Promise { + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("ping timeout")), ms); + }); + try { + await Promise.race([this.neo4j.ping(), timeout]); + } finally { + clearTimeout(timer!); + } + } +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts new file mode 100644 index 0000000..3fde37c --- /dev/null +++ b/apps/server/src/main.ts @@ -0,0 +1,167 @@ +import "reflect-metadata"; +// Load .env file FIRST from config/env.ts import (env.ts parses at boot time) +import "dotenv/config"; +import { NestFactory } from "@nestjs/core"; +import type { NestExpressApplication } from "@nestjs/platform-express"; +import helmet from "helmet"; +import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger"; +import { apiReference } from "@scalar/nestjs-api-reference"; +import { cleanupOpenApiDoc, ZodValidationPipe as ZodPipe } from "nestjs-zod"; +import { AppModule } from "./app.module"; +import { env } from "./config/env"; +import { warnMissingEnv } from "./config/env-check"; +import { SchemaErrorFilter } from "./common/filters/schema-error.filter"; +import { NotFoundFilter } from "./common/filters/not-found.filter"; +import { ConflictFilter } from "./common/filters/conflict.filter"; +import { InternalFilter } from "./common/filters/internal.filter"; +import { UnauthorizedFilter } from "./common/filters/unauthorized.filter"; +import { ForbiddenFilter } from "./common/filters/forbidden.filter"; +const API_DESCRIPTION = ` +A graph backend that grounds software architecture drawn via natural language / sketch into **strict schema standards** and blocks architectural violations with a **Rules Engine**. + +## General Flow + +1. **Create a project** — \`POST /projects\` +2. **Add a node** — \`POST /projects/{projectId}/nodes\` (Service, Table, Controller, ...) +3. **Validate a connection** — \`POST /projects/{projectId}/edges/validate\` (Rules Engine pre-check) +4. **Create a connection** — \`POST /projects/{projectId}/edges\` (CALLS, WRITES, ...) +5. **Fetch the whole graph** — \`GET /projects/{projectId}/graph\` + +## Response Envelope + +All responses use a consistent envelope format: + +- **Success:** \`{ "success": true, "data": { ... } }\` +- **Error:** \`{ "success": false, "error": { "code": "ERR_...", "message": "...", "details"?: [...] } }\` + +## Key Error Codes + +| Code | Meaning | +|-----|--------| +| \`ERR_SCHEMA_INVALID\` | The submitted body does not match the Zod schema (400) | +| \`ERR_PROJECT_NOT_FOUND\` | The project does not exist (404) | +| \`ERR_NODE_NOT_FOUND\` / \`ERR_EDGE_NOT_FOUND\` | The record does not exist (404) | +| \`ERR_NAME_DUPLICATE\` | Duplicate name within the project (409) | +| \`ERR_001..ERR_007\` | Architectural prohibition (Rules blacklist) | +| \`ERR_COND_001\` | Circular dependency | +| \`ERR_NOT_WHITELISTED\` | The connection is not in the allow list (default deny) | + +## Architectural Discipline + +Any node→edge→node connection that is not explicitly specified is **forbidden by default**. See the \`GET /rules\` catalog for allowed connections. +`; + +async function bootstrap() { +// bodyParser:false → close default 100kb parser; below is single 1mb parser +// we set up (otherwise the two parsers will be chained, the default limit remains valid). +// rawBody:true is preserved (derives from core appOptions.rawBody) → webhook signature works. + const app = await NestFactory.create(AppModule, { + rawBody: true, + bodyParser: false, + }); +// Get real client IP from X-Forwarded-For behind reverse proxy (Caddy/nginx) +// → rate-limit IP fallback works based on the real IP, not the proxy IP. + app.set("trust proxy", 1); +// Security headers. CSP off: it's a JSON API + Scalar docs (inline +// script/style) page; It breaks the CSP docs UI, JSON is meaningless in responses. + app.use(helmet({ contentSecurityPolicy: false })); +// Body size limit — prevent memory DoS with unlimited JSON/batch. +// 1mb: chat history (≤50×8000 char) + large graph apply fits comfortably; MB abuse stops. + app.useBodyParser("json", { limit: "1mb" }); + app.useBodyParser("urlencoded", { limit: "1mb", extended: true }); + app.setGlobalPrefix("api/v1"); + app.enableCors({ origin: env.CORS_ORIGIN, credentials: true }); +// Report missing env values ​​one by one (which feature does not work and why). + warnMissingEnv(); +// nestjs-zod global pipe — Gets automatic Zod schema from DTO class, +// Swagger Module also recognizes DTO metadata with this pipe. + app.useGlobalPipes(new ZodPipe()); + app.useGlobalFilters( + new InternalFilter(), + new UnauthorizedFilter(), + new ForbiddenFilter(), + new ConflictFilter(), + new NotFoundFilter(), + new SchemaErrorFilter(), + ); + +// OpenAPI + Scalar dev/test only — do not leak full API surface in production. + if (env.NODE_ENV !== "production") { + const config = new DocumentBuilder() + .setTitle("Solarch Backend API") + .setDescription(API_DESCRIPTION) + .setVersion("0.1.0") + .addServer(`http://localhost:${env.PORT}`, "Local development") + .addTag("Projects", "Architecture project (workspace) management. Each project contains an architecture graph — nodes and edges belong to a project. The project must exist before creating nodes/edges (strict integrity).") + .addTag("Nodes", "The building blocks in a project (Table, Service, Controller, ...) — 21 types. Each node carries a kind (`type`) + kind-specific `properties`. Schema validation is done with Zod.") + .addTag("Node Types", "Node type catalog (read-only). Which types exist, each one's JSON Schema and architecture rules. Feeds the frontend forms from this endpoint.") + .addTag("Edges", "Directed connections between nodes (CALLS, WRITES, PUBLISHES, ...) — 16 types. The Rules Engine applies when each edge is created; architectural violations are rejected.") + .addTag("Edge Types", "Edge type catalog (read-only). Each connection type's direction, meaning, example source/target and rules.") + .addTag("Rules", "Architecture Rules Engine. A catalog of allowed (whitelist), forbidden (blacklist: ERR_001..007) and conditional (circular dependency, type mismatch, empty schema) rules.") + .addTag("Health", "Service health check (liveness/readiness).") + .build(); + const document = SwaggerModule.createDocument(app, config); + const cleanDoc = cleanupOpenApiDoc(document); + inlineAllRefs(cleanDoc); + if (cleanDoc.components) cleanDoc.components.schemas = {}; + + app.use("/api/v1/openapi.json", (_req: unknown, res: any) => { + res.json(cleanDoc); + }); + app.use( + "/api/v1/docs", + apiReference({ + content: cleanDoc, + theme: "purple", + }), + ); + } + +// Run Nest onModuleDestroy chain in SIGTERM/SIGINT (Neo4j driver.close) + +// Let the HTTP server gracefully shut down. NO manual signal handler required. + app.enableShutdownHooks(); + + // Bind to env.HOST (default 127.0.0.1): on a single box only the local reverse proxy + // (Caddy) reaches the backend; all traffic is single-origin. In Docker, HOST=0.0.0.0 + // so the proxy container can reach server:PORT (the port is never published to the host). + await app.listen(env.PORT, env.HOST); + console.log(`solarch-backend listening on http://${env.HOST}:${env.PORT}`); + if (env.NODE_ENV !== "production") { + console.log(`API docs (Scalar): http://127.0.0.1:${env.PORT}/api/v1/docs`); + } +} + +bootstrap(); + +/** Resolves each $ref in the OpenAPI document and replaces it with an inline schema. +* Then add components.schemas so that the Scalar Models panel remains empty. +* we can safely delete it. */ +function inlineAllRefs(doc: any): void { + const schemas = doc.components?.schemas ?? {}; + const resolve = (ref: string): unknown => { + // "#/components/schemas/CreateNodeDto" -> "CreateNodeDto" + const name = ref.replace("#/components/schemas/", ""); + return schemas[name]; + }; + const visit = (node: unknown, seen = new WeakSet()): unknown => { + if (!node || typeof node !== "object") return node; + if (seen.has(node as object)) return node; + seen.add(node as object); + if (Array.isArray(node)) { + for (let i = 0; i < node.length; i++) node[i] = visit(node[i], seen); + return node; + } + const obj = node as Record; + if (typeof obj.$ref === "string") { + const resolved = resolve(obj.$ref); + if (resolved && typeof resolved === "object") { + delete obj.$ref; + Object.assign(obj, JSON.parse(JSON.stringify(resolved))); + return visit(obj, seen); + } + } + for (const k of Object.keys(obj)) obj[k] = visit(obj[k], seen); + return obj; + }; + if (doc.paths) visit(doc.paths); +} diff --git a/apps/server/src/neo4j/migrations/001_constraints.cypher b/apps/server/src/neo4j/migrations/001_constraints.cypher new file mode 100644 index 0000000..f73e937 --- /dev/null +++ b/apps/server/src/neo4j/migrations/001_constraints.cypher @@ -0,0 +1,8 @@ +CREATE CONSTRAINT node_id_unique IF NOT EXISTS + FOR (n:Node) REQUIRE n.id IS UNIQUE; + +CREATE INDEX node_project_idx IF NOT EXISTS + FOR (n:Node) ON (n.projectId); + +CREATE CONSTRAINT project_id_unique IF NOT EXISTS + FOR (p:Project) REQUIRE p.id IS UNIQUE; diff --git a/apps/server/src/neo4j/migrations/002_auth.cypher b/apps/server/src/neo4j/migrations/002_auth.cypher new file mode 100644 index 0000000..c661fd0 --- /dev/null +++ b/apps/server/src/neo4j/migrations/002_auth.cypher @@ -0,0 +1,8 @@ +-- Multi-tenancy — indexes for Project ownership fields. +-- Users are not stored in Neo4j; ownerId/orgId are opaque strings from auth context. + +CREATE INDEX project_owner_idx IF NOT EXISTS + FOR (p:Project) ON (p.ownerId); + +CREATE INDEX project_org_idx IF NOT EXISTS + FOR (p:Project) ON (p.orgId); diff --git a/apps/server/src/neo4j/migrations/002_tab_constraint.cypher b/apps/server/src/neo4j/migrations/002_tab_constraint.cypher new file mode 100644 index 0000000..29bb6be --- /dev/null +++ b/apps/server/src/neo4j/migrations/002_tab_constraint.cypher @@ -0,0 +1 @@ +CREATE CONSTRAINT tab_id_unique IF NOT EXISTS FOR (t:Tab) REQUIRE t.id IS UNIQUE; diff --git a/apps/server/src/neo4j/migrations/004_node_version.cypher b/apps/server/src/neo4j/migrations/004_node_version.cypher new file mode 100644 index 0000000..dac843c --- /dev/null +++ b/apps/server/src/neo4j/migrations/004_node_version.cypher @@ -0,0 +1,4 @@ +// Optimistic concurrency: mevcut tum node'lara baslangic version'u (idempotent). +// NOT: yorum '//' ile (Neo4j inline comment); '--' kullanma — runner '--' ile +// baslayan statement chunk'ini atlar, backfill calismadan gecer. +MATCH (n:Node) WHERE n.version IS NULL SET n.version = 1; diff --git a/apps/server/src/neo4j/migrations/005_api_keys.cypher b/apps/server/src/neo4j/migrations/005_api_keys.cypher new file mode 100644 index 0000000..c4f5daa --- /dev/null +++ b/apps/server/src/neo4j/migrations/005_api_keys.cypher @@ -0,0 +1,11 @@ +-- API keys (CLI/MCP client identity) — keys are not stored in plain text, +-- only SHA-256 hash. Indexes for hash lookup and per-user listing. + +CREATE CONSTRAINT api_key_id_unique IF NOT EXISTS + FOR (k:ApiKey) REQUIRE k.id IS UNIQUE; + +CREATE CONSTRAINT api_key_hash_unique IF NOT EXISTS + FOR (k:ApiKey) REQUIRE k.hash IS UNIQUE; + +CREATE INDEX api_key_user_idx IF NOT EXISTS + FOR (k:ApiKey) ON (k.userId); diff --git a/apps/server/src/neo4j/migrations/006_graph_revision.cypher b/apps/server/src/neo4j/migrations/006_graph_revision.cypher new file mode 100644 index 0000000..3d3dbd4 --- /dev/null +++ b/apps/server/src/neo4j/migrations/006_graph_revision.cypher @@ -0,0 +1,5 @@ +-- Graph-level revision counter (Solarch 2.0 Phase 2) — incremented on each structural +-- mutation (node/edge create-update-delete, graph/apply). CLI push conflict detection +-- relies on this counter. Backfill existing projects to 0. + +MATCH (p:Project) WHERE p.graphRevision IS NULL SET p.graphRevision = 0; diff --git a/apps/server/src/neo4j/migrations/data/001-enrich-faz-a.ts b/apps/server/src/neo4j/migrations/data/001-enrich-faz-a.ts new file mode 100644 index 0000000..85dd68f --- /dev/null +++ b/apps/server/src/neo4j/migrations/data/001-enrich-faz-a.ts @@ -0,0 +1,97 @@ +import { Neo4jService } from "../../neo4j.service"; +import { env } from "../../../config/env"; + +/** Phase A data migration: converts existing Data-family nodes (Table/DTO/Model/ + * Enum/View) to enriched v2 schema. + * + * Idempotent — fills missing required arrays with defaults, migrates legacy fields + * (Column.IsForeignKey/References, string[] Enum Values) to new shape. + * Safe to re-run. */ +async function main(): Promise { + const svc = new Neo4jService({ + uri: env.NEO4J_URI, + user: env.NEO4J_USER, + password: env.NEO4J_PASSWORD, + }); + await svc.onModuleInit(); + + const kinds = ["Table", "DTO", "Model", "Enum", "View"]; + let migrated = 0; + for (const kind of kinds) { + const res = await svc.run( + `MATCH (n:\`${kind}\`) RETURN n.id AS id, n.properties AS props`, + ); + for (const rec of res.records) { + const id = rec.get("id"); + const props = JSON.parse(rec.get("props")); + const next = enrich(kind, props); + await svc.run(`MATCH (n {id: $id}) SET n.properties = $props`, { + id, + props: JSON.stringify(next), + }); + migrated++; + } + } + + await svc.onModuleDestroy(); + console.log(`✓ Phase A migration: ${migrated} nodes converted.`); +} + +function enrich(kind: string, p: any): any { + if (kind === "Table") { + return { + ...p, + ForeignKeys: p.ForeignKeys ?? [], + UniqueConstraints: p.UniqueConstraints ?? [], + CheckConstraints: p.CheckConstraints ?? [], + Indexes: (p.Indexes ?? []).map((i: any) => ({ Type: "BTree", IsUnique: false, ...i })), + Columns: (p.Columns ?? []).map((c: any) => { + const { IsForeignKey, References, ...rest } = c; // drop legacy fields + return rest; + }), + }; + } + if (kind === "DTO") { + const KNOWN = ["Min", "Max", "MinLength", "MaxLength", "Email", "Url", "Regex", "Pattern", "Positive", "Negative"]; + const norm = (raw: unknown) => KNOWN.find((k) => k.toLowerCase() === String(raw).toLowerCase()); + return { + ...p, + Fields: (p.Fields ?? []).map((f: any) => { + const { ValidationRule, ValidationRules, ...rest } = f; + // Source: structural ValidationRules[] if present, else legacy string. + const source = ValidationRules ?? (ValidationRule ? [{ Rule: ValidationRule }] : []); + // Normalize Rule to enum (case-insensitive); drop free-text/unrecognized. + const cleaned = source + .map((r: any) => { + const matched = norm(r.Rule); + return matched ? { ...r, Rule: matched } : null; + }) + .filter(Boolean); + return { ...rest, ValidationRules: cleaned }; + }), + }; + } + if (kind === "Model") { + return { + ...p, + Properties: (p.Properties ?? []).map((pr: any) => ({ IsNullable: false, IsCollection: false, ...pr })), + Methods: (p.Methods ?? []).map((m: any) => ({ Visibility: "public", Parameters: [], IsAsync: false, IsStatic: false, ...m })), + }; + } + if (kind === "Enum") { + return { + ...p, + BackingType: p.BackingType ?? "string", + Values: (p.Values ?? []).map((v: any) => (typeof v === "string" ? { Key: v } : v)), + }; + } + if (kind === "View") { + return { ...p, Columns: p.Columns ?? [] }; + } + return p; +} + +main().catch((e) => { + console.error("✗ Migration failed:", e); + process.exit(1); +}); diff --git a/apps/server/src/neo4j/migrations/data/002-enrich-faz-b.ts b/apps/server/src/neo4j/migrations/data/002-enrich-faz-b.ts new file mode 100644 index 0000000..2face96 --- /dev/null +++ b/apps/server/src/neo4j/migrations/data/002-enrich-faz-b.ts @@ -0,0 +1,100 @@ +import { Neo4jService } from "../../neo4j.service"; +import { env } from "../../../config/env"; + +/** Phase B data migration: moves Business Logic + Access nodes (Service/Worker/ + * EventHandler/Orchestrator/Controller/MessageQueue/APIGateway) to v3 schema. + * + * Idempotent — converts breaking renames, defaults missing arrays. + * Safe to re-run (already-migrated fields are no-op). */ +async function main(): Promise { + const svc = new Neo4jService({ + uri: env.NEO4J_URI, + user: env.NEO4J_USER, + password: env.NEO4J_PASSWORD, + }); + await svc.onModuleInit(); + + const kinds = ["Service", "Worker", "EventHandler", "Orchestrator", "Controller", "MessageQueue", "APIGateway"]; + let migrated = 0; + for (const kind of kinds) { + const res = await svc.run( + `MATCH (n:\`${kind}\`) RETURN n.id AS id, n.properties AS props`, + ); + for (const rec of res.records) { + const id = rec.get("id"); + const props = JSON.parse(rec.get("props")); + const next = enrich(kind, props); + await svc.run(`MATCH (n {id: $id}) SET n.properties = $props`, { + id, + props: JSON.stringify(next), + }); + migrated++; + } + } + + await svc.onModuleDestroy(); + console.log(`✓ Phase B migration: ${migrated} nodes converted.`); +} + +function enrich(kind: string, p: any): any { + if (kind === "Service") { + return { + ...p, + Dependencies: p.Dependencies ?? [], + Methods: (p.Methods ?? []).map((m: any) => { + const { InputParams, ...rest } = m; // eski InputParams → Parameters + return { + Visibility: "public", + IsAsync: false, + Throws: [], + ...rest, + Parameters: rest.Parameters ?? InputParams ?? [], + }; + }), + }; + } + if (kind === "Worker") { + // RetryPolicy: number → { MaxRetries } + const rp = typeof p.RetryPolicy === "number" ? { MaxRetries: p.RetryPolicy } : p.RetryPolicy ?? { MaxRetries: 0 }; + return { IsEnabled: true, ...p, RetryPolicy: rp }; + } + if (kind === "EventHandler") { + return { ...p }; // new fields all optional — no transform needed + } + if (kind === "Orchestrator") { + return { ...p, Steps: p.Steps ?? [] }; + } + if (kind === "Controller") { + return { + ...p, + Endpoints: (p.Endpoints ?? []).map((e: any) => { + const { RequestDTO, ResponseDTO, ...rest } = e; // eski → Ref + const out: any = { + RequiredRoles: [], + PathParams: [], + QueryParams: [], + StatusCodes: [], + MiddlewareRefs: [], + ...rest, + RequestDTORef: rest.RequestDTORef ?? RequestDTO, + ResponseDTORef: rest.ResponseDTORef ?? ResponseDTO, + }; + if (out.RequestDTORef === undefined) delete out.RequestDTORef; + if (out.ResponseDTORef === undefined) delete out.ResponseDTORef; + return out; + }), + }; + } + if (kind === "MessageQueue") { + return { ...p }; // yeni alanlar opsiyonel + } + if (kind === "APIGateway") { + return { ...p, Routes: p.Routes ?? [] }; + } + return p; +} + +main().catch((e) => { + console.error("✗ Migration failed:", e); + process.exit(1); +}); diff --git a/apps/server/src/neo4j/migrations/data/003-enrich-faz-c.ts b/apps/server/src/neo4j/migrations/data/003-enrich-faz-c.ts new file mode 100644 index 0000000..4c9f3bc --- /dev/null +++ b/apps/server/src/neo4j/migrations/data/003-enrich-faz-c.ts @@ -0,0 +1,81 @@ +import { Neo4jService } from "../../neo4j.service"; +import { env } from "../../../config/env"; + +/** Phase C data migration: moves Infrastructure/Client/Security/Config/Structure nodes + * (Repository/Cache/ExternalService/FrontendApp/UIComponent/Middleware/ + * EnvironmentVariable/Exception/Module) to v4 schema. + * + * Idempotent. Single breaking transform: Repository.CustomQueries string[] → object[]. + * Remaining new fields are additive (optional/default) — missing default arrays + * filled for consistency. */ +async function main(): Promise { + const svc = new Neo4jService({ + uri: env.NEO4J_URI, + user: env.NEO4J_USER, + password: env.NEO4J_PASSWORD, + }); + await svc.onModuleInit(); + + const kinds = ["Repository", "Cache", "ExternalService", "FrontendApp", "UIComponent", "Middleware", "EnvironmentVariable", "Exception", "Module"]; + let migrated = 0; + for (const kind of kinds) { + const res = await svc.run( + `MATCH (n:\`${kind}\`) RETURN n.id AS id, n.properties AS props`, + ); + for (const rec of res.records) { + const id = rec.get("id"); + const props = JSON.parse(rec.get("props")); + const next = enrich(kind, props); + await svc.run(`MATCH (n {id: $id}) SET n.properties = $props`, { + id, + props: JSON.stringify(next), + }); + migrated++; + } + } + + await svc.onModuleDestroy(); + console.log(`✓ Phase C migration: ${migrated} nodes converted.`); +} + +function enrich(kind: string, p: any): any { + if (kind === "Repository") { + return { + IsCached: false, + ...p, + CustomQueries: (p.CustomQueries ?? []).map((q: any) => + typeof q === "string" + ? { QueryName: q, QueryType: "custom", Parameters: [], ReturnType: "unknown" } + : { QueryType: "custom", Parameters: [], ...q }, + ), + }; + } + if (kind === "FrontendApp") { + return { ...p, Routes: p.Routes ?? [] }; + } + if (kind === "UIComponent") { + return { + ...p, + Props: (p.Props ?? []).map((pr: any) => ({ Required: false, ...pr })), + State: p.State ?? [], + Events: p.Events ?? [], + ChildComponentRefs: p.ChildComponentRefs ?? [], + }; + } + if (kind === "Middleware") { + return { ...p, Config: p.Config ?? [] }; + } + if (kind === "EnvironmentVariable") { + return { IsRequired: true, ...p }; + } + if (kind === "Module") { + return { ...p, ExposedServices: p.ExposedServices ?? [], Dependencies: p.Dependencies ?? [] }; + } + // Cache / ExternalService / Exception: new fields all optional — no transform needed. + return p; +} + +main().catch((e) => { + console.error("✗ Migration failed:", e); + process.exit(1); +}); diff --git a/apps/server/src/neo4j/migrations/data/004-pattern-vector-index.ts b/apps/server/src/neo4j/migrations/data/004-pattern-vector-index.ts new file mode 100644 index 0000000..cb01593 --- /dev/null +++ b/apps/server/src/neo4j/migrations/data/004-pattern-vector-index.ts @@ -0,0 +1,32 @@ +import { Neo4jService } from "../../neo4j.service"; +import { env } from "../../../config/env"; + +/** Native vector index for :Pattern(embedding). Idempotent (IF NOT EXISTS). + * Dimension from env.EMBED_DIM (local all-MiniLM-L6-v2 = 384). If model/dimension + * changes, index DROP + recreate is required. */ +async function main(): Promise { + const svc = new Neo4jService({ + uri: env.NEO4J_URI, + user: env.NEO4J_USER, + password: env.NEO4J_PASSWORD, + }); + await svc.onModuleInit(); + // Index config map does not accept params + requires INTEGER (param becomes float). + // EMBED_DIM is trusted env int → embed as literal. + const dim = Math.trunc(env.EMBED_DIM); + await svc.run( + `CREATE VECTOR INDEX pattern_embedding IF NOT EXISTS + FOR (p:Pattern) ON (p.embedding) + OPTIONS { indexConfig: { + \`vector.dimensions\`: ${dim}, + \`vector.similarity_function\`: 'cosine' + } }`, + ); + await svc.onModuleDestroy(); + console.log(`✓ pattern_embedding vector index ready (dim=${env.EMBED_DIM}).`); +} + +main().catch((e) => { + console.error("✗ Index migration failed:", e); + process.exit(1); +}); diff --git a/apps/server/src/neo4j/migrations/data/005-tabs.ts b/apps/server/src/neo4j/migrations/data/005-tabs.ts new file mode 100644 index 0000000..76082f3 --- /dev/null +++ b/apps/server/src/neo4j/migrations/data/005-tabs.ts @@ -0,0 +1,50 @@ +import { randomUUID } from "node:crypto"; +import { Neo4jService } from "../../neo4j.service"; +import { env } from "../../../config/env"; + +/** Her projeye default "Ana Mimari" sekmesi + her node'a homeTabId backfill. + * Idempotent — node.position KORUNUR. */ +async function main(): Promise { + const svc = new Neo4jService({ uri: env.NEO4J_URI, user: env.NEO4J_USER, password: env.NEO4J_PASSWORD }); + await svc.onModuleInit(); + + const projects = await svc.run(`MATCH (p:Project) RETURN p.id AS id`); + let tabs = 0; + let backfilled = 0; + for (const rec of projects.records) { + const projectId = rec.get("id"); + const def = await svc.run( + `MATCH (t:Tab {projectId: $projectId, isDefault: true}) RETURN t.id AS id LIMIT 1`, + { projectId }, + ); + let tabId: string; + if (def.records.length === 0) { + tabId = randomUUID(); + const now = new Date().toISOString(); + await svc.run( + `CREATE (t:Tab { + id: $id, projectId: $projectId, name: 'Main Architecture', isDefault: true, + order: 0, moduleNodeId: null, createdAt: datetime($now), updatedAt: datetime($now) + })`, + { id: tabId, projectId, now }, + ); + tabs++; + } else { + tabId = def.records[0].get("id"); + } + const res = await svc.run( + `MATCH (n:Node {projectId: $projectId}) WHERE n.homeTabId IS NULL + SET n.homeTabId = $tabId RETURN count(n) AS c`, + { projectId, tabId }, + ); + backfilled += Number(res.records[0].get("c")); + } + + await svc.onModuleDestroy(); + console.log(`✓ Tabs migration: ${tabs} default sekme, ${backfilled} node homeTabId backfill.`); +} + +main().catch((e) => { + console.error("✗ Tabs migration failed:", e); + process.exit(1); +}); diff --git a/apps/server/src/neo4j/migrations/run.ts b/apps/server/src/neo4j/migrations/run.ts new file mode 100644 index 0000000..deefbb6 --- /dev/null +++ b/apps/server/src/neo4j/migrations/run.ts @@ -0,0 +1,44 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { Neo4jService } from "../neo4j.service"; +import { env } from "../../config/env"; + +async function main() { + const service = new Neo4jService({ + uri: env.NEO4J_URI, + user: env.NEO4J_USER, + password: env.NEO4J_PASSWORD, + }); + await service.onModuleInit(); + + const dir = __dirname; + const files = readdirSync(dir).filter((f) => f.endsWith(".cypher")).sort(); + + for (const file of files) { + const cypher = readFileSync(join(dir, file), "utf-8"); + // Comment LINES are stripped (bug: comment at chunk start dropped entire statement — + // 005's first constraint was silently skipped). + const statements = cypher + .split(/;\s*$/m) + .map((s) => + s + .split("\n") + .filter((line) => !line.trim().startsWith("--")) + .join("\n") + .trim(), + ) + .filter(Boolean); + for (const stmt of statements) { + console.log(`[${file}] ${stmt.slice(0, 80)}...`); + await service.run(stmt); + } + } + + await service.onModuleDestroy(); + console.log("✓ Migrations complete."); +} + +main().catch((err) => { + console.error("✗ Migration failed:", err); + process.exit(1); +}); diff --git a/apps/server/src/neo4j/neo4j.module.ts b/apps/server/src/neo4j/neo4j.module.ts new file mode 100644 index 0000000..259edca --- /dev/null +++ b/apps/server/src/neo4j/neo4j.module.ts @@ -0,0 +1,24 @@ +import { Global, Module } from "@nestjs/common"; +import { Neo4jService } from "./neo4j.service"; +import { env } from "../config/env"; + +@Global() +@Module({ + providers: [ + { + provide: Neo4jService, + useFactory: () => new Neo4jService({ + uri: env.NEO4J_URI, + user: env.NEO4J_USER, + password: env.NEO4J_PASSWORD, + maxConnectionPoolSize: env.NEO4J_MAX_POOL_SIZE, + connectionAcquisitionTimeout: env.NEO4J_CONNECTION_ACQUISITION_TIMEOUT_MS, + connectionTimeout: env.NEO4J_CONNECTION_TIMEOUT_MS, + maxTransactionRetryTime: env.NEO4J_MAX_TX_RETRY_TIME_MS, + maxConnectionLifetime: env.NEO4J_MAX_CONNECTION_LIFETIME_MS, + }), + }, + ], + exports: [Neo4jService], +}) +export class Neo4jModule {} diff --git a/apps/server/src/neo4j/neo4j.service.spec.ts b/apps/server/src/neo4j/neo4j.service.spec.ts new file mode 100644 index 0000000..90dcbd4 --- /dev/null +++ b/apps/server/src/neo4j/neo4j.service.spec.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Neo4jContainer, StartedNeo4jContainer } from "@testcontainers/neo4j"; +import { Neo4jService } from "./neo4j.service"; + +describe("Neo4jService", () => { + let container: StartedNeo4jContainer; + let service: Neo4jService; + + beforeAll(async () => { + container = await new Neo4jContainer("neo4j:5-community").withApoc().start(); + service = new Neo4jService({ + uri: container.getBoltUri(), + user: container.getUsername(), + password: container.getPassword(), + }); + await service.onModuleInit(); + }, 180_000); + + afterAll(async () => { + await service.onModuleDestroy(); + await container.stop(); + }); + +it("ping works (returns 1)", async () => { + const result = await service.run("RETURN 1 AS n"); + expect(result.records[0].get("n")).toBe(1); + }); + + it("ping() readiness — DB ayaktayken resolve eder", async () => { + await expect(service.ping()).resolves.toBeUndefined(); + }); + +it("writes in transaction", async () => { + await service.write(async (tx) => { + await tx.run("CREATE (n:Test {id: 't1'})"); + }); + const result = await service.run("MATCH (n:Test {id: 't1'}) RETURN n.id AS id"); + expect(result.records[0].get("id")).toBe("t1"); + }); +}); diff --git a/apps/server/src/neo4j/neo4j.service.ts b/apps/server/src/neo4j/neo4j.service.ts new file mode 100644 index 0000000..323dd95 --- /dev/null +++ b/apps/server/src/neo4j/neo4j.service.ts @@ -0,0 +1,76 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common"; +import neo4j, { Driver, Session, ManagedTransaction, QueryResult } from "neo4j-driver"; + +export interface Neo4jConfig { + uri: string; + user: string; + password: string; + // Connection pool / timeout (opsiyonel — verilmezse makul launch default'u). + maxConnectionPoolSize?: number; + connectionAcquisitionTimeout?: number; + connectionTimeout?: number; + maxTransactionRetryTime?: number; + maxConnectionLifetime?: number; +} + +@Injectable() +export class Neo4jService implements OnModuleInit, OnModuleDestroy { + private driver!: Driver; + + constructor(private readonly config: Neo4jConfig) {} + + async onModuleInit(): Promise { + this.driver = neo4j.driver( + this.config.uri, + neo4j.auth.basic(this.config.user, this.config.password), + { + disableLosslessIntegers: true, +// ?? defaults: migration/seed/test calling with {uri,user,password} +// call-sites (does not pass pool config) take reasonable default without breaking. + maxConnectionPoolSize: this.config.maxConnectionPoolSize ?? 50, + connectionAcquisitionTimeout: this.config.connectionAcquisitionTimeout ?? 60_000, + connectionTimeout: this.config.connectionTimeout ?? 30_000, + maxTransactionRetryTime: this.config.maxTransactionRetryTime ?? 30_000, + maxConnectionLifetime: this.config.maxConnectionLifetime ?? 3_600_000, + }, + ); + await this.driver.verifyConnectivity(); + } + + async onModuleDestroy(): Promise { + await this.driver?.close(); + } + +/** Readiness check — retrieves an actual session+query from the pool (RETURN 1). +* Throws (caller casts 503) if DB is unreachable. More representative than verifyConnectivity. */ + async ping(): Promise { + await this.run("RETURN 1 AS ok"); + } + + async run(cypher: string, params?: Record): Promise { + const session: Session = this.driver.session(); + try { + return await session.run(cypher, params); + } finally { + await session.close(); + } + } + + async write(work: (tx: ManagedTransaction) => Promise): Promise { + const session: Session = this.driver.session(); + try { + return await session.executeWrite(work); + } finally { + await session.close(); + } + } + + async read(work: (tx: ManagedTransaction) => Promise): Promise { + const session: Session = this.driver.session(); + try { + return await session.executeRead(work); + } finally { + await session.close(); + } + } +} diff --git a/apps/server/src/node-types/node-types.controller.ts b/apps/server/src/node-types/node-types.controller.ts new file mode 100644 index 0000000..d1e00c8 --- /dev/null +++ b/apps/server/src/node-types/node-types.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Get, Param } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiParam, ApiResponse } from "@nestjs/swagger"; +import { NodeTypesService } from "./node-types.service"; +import { ok } from "../common/envelope"; + +@ApiTags("Node Types") +@Controller("node-types") +export class NodeTypesController { + constructor(private readonly service: NodeTypesService) {} + + @Get() + @ApiOperation({ + summary: "List all node types", + description: + "Returns a summary of the 21 node types: `id`, `family` (Data/Business/Access/...), `nameKey` (the field unique within the project) and a short description. Populate the frontend's 'Add New Node' menu from this endpoint.", + }) + @ApiResponse({ status: 200, description: "`data: { types: [...], total: 21 }`." }) + listAll() { + const types = this.service.listAll(); + return ok({ types, total: types.length }); + } + + @Get(":typeId") + @ApiOperation({ + summary: "Single node type (+ JSON Schema)", + description: + "Metadata of the given node type + its **full JSON Schema** (generated with zodV3ToOpenAPI). The frontend renders its dynamic form from this schema.", + }) + @ApiParam({ name: "typeId", description: "Node kind (e.g. `Table`, `Service`, `Controller`)", example: "Table" }) + @ApiResponse({ status: 200, description: "Metadata + `schema` (JSON Schema)." }) + @ApiResponse({ status: 404, description: "`ERR_NODE_TYPE_NOT_FOUND` — valid types are listed in the message." }) + getById(@Param("typeId") typeId: string) { + return ok(this.service.getById(typeId)); + } + + @Get(":typeId/rule") + @ApiOperation({ + summary: "Architecture rules for the node type", + description: + "This node type's place in the Rules Engine: in which connections it is **allowed as source/target** (`allowAsSource`/`allowAsTarget`) and in which it is **forbidden** (`denyAsSource`/`denyAsTarget`, with ERR codes).", + }) + @ApiParam({ name: "typeId", description: "Node kind", example: "Service" }) + @ApiResponse({ status: 200, description: "allow/deny rule lists." }) + @ApiResponse({ status: 404, description: "`ERR_NODE_TYPE_NOT_FOUND`." }) + getRules(@Param("typeId") typeId: string) { + return ok(this.service.getRulesById(typeId)); + } +} diff --git a/apps/server/src/node-types/node-types.module.ts b/apps/server/src/node-types/node-types.module.ts new file mode 100644 index 0000000..57e6c72 --- /dev/null +++ b/apps/server/src/node-types/node-types.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { NodeTypesController } from "./node-types.controller"; +import { NodeTypesService } from "./node-types.service"; +import { RulesModule } from "../rules/rules.module"; + +@Module({ + imports: [RulesModule], + controllers: [NodeTypesController], + providers: [NodeTypesService], +}) +export class NodeTypesModule {} diff --git a/apps/server/src/node-types/node-types.service.spec.ts b/apps/server/src/node-types/node-types.service.spec.ts new file mode 100644 index 0000000..9ddf294 --- /dev/null +++ b/apps/server/src/node-types/node-types.service.spec.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from "vitest"; +import { NotFoundException } from "@nestjs/common"; +import { NodeTypesService } from "./node-types.service"; + +describe("NodeTypesService", () => { + const mockEngine = { + rulesForNodeKind: vi.fn(() => ({ + allowAsSource: [], allowAsTarget: [], denyAsSource: [], denyAsTarget: [], + })), + }; + const service = new NodeTypesService(mockEngine as any); + + it("listAll returns 21 types (19 + Phase 2A: APIGateway, Orchestrator)", () => { + const list = service.listAll(); + expect(list).toHaveLength(21); + const ids = list.map((t) => t.id); + expect(ids).toContain("Table"); + expect(ids).toContain("Service"); + expect(ids).toContain("Module"); + expect(ids).toContain("APIGateway"); + expect(ids).toContain("Orchestrator"); + }); + + it("listAll includes family + nameKey for each type", () => { + const list = service.listAll(); + for (const t of list) { + expect(t.family).toBeDefined(); + expect(t.familyLabel).toBeDefined(); + expect(t.nameKey).toMatch(/^[A-Z]/); + } + }); + + it("getById returns JSON Schema for Table", () => { + const detail = service.getById("Table"); + expect(detail.id).toBe("Table"); + expect(detail.nameKey).toBe("TableName"); + expect(detail.schema).toBeDefined(); + }); + + it("getById returns Table fieldHints (PK/FK badge)", () => { + const d = service.getById("Table") as any; + expect(d.fieldHints["Columns.IsPrimaryKey"].badge).toBe("PK"); + expect(d.fieldHints["ForeignKeys"].badge).toBe("FK"); + expect(d.fieldHints["Indexes"].group).toBe("performance"); + }); + + it("getById returns Enum fieldHints", () => { + expect((service.getById("Enum") as any).fieldHints["Values"].badge).toBe("ENUM"); + }); + + it("getById returns Phase B fieldHints (Service DI, Controller AUTH)", () => { + expect((service.getById("Service") as any).fieldHints["Dependencies"].badge).toBe("DI"); + expect((service.getById("Controller") as any).fieldHints["Endpoints.RequiresAuth"].badge).toBe("AUTH"); + expect((service.getById("Worker") as any).fieldHints["RetryPolicy"].badge).toBe("RETRY"); + }); + + it("getById returns Phase C fieldHints (Cache TTL, EnvVar SECRET, Module DEP)", () => { + expect((service.getById("Cache") as any).fieldHints["TTL_Seconds"].badge).toBe("TTL"); + expect((service.getById("EnvironmentVariable") as any).fieldHints["IsSecret"].badge).toBe("SECRET"); + expect((service.getById("Module") as any).fieldHints["Dependencies"].badge).toBe("DEP"); + // fieldHints now populated for all 21 types (no empty type left). + for (const t of service.listAll()) { + expect(Object.keys((service.getById(t.id) as any).fieldHints).length).toBeGreaterThan(0); + } + }); + + it("getById throws NotFoundException for unknown id", () => { + expect(() => service.getById("Foo")).toThrow(NotFoundException); + }); + + it("getRulesById returns allow/deny lists from engine", () => { + const r = service.getRulesById("Table"); + expect(r.id).toBe("Table"); + expect(r.allowAsSource).toBeDefined(); + expect(r.allowAsTarget).toBeDefined(); + expect(r.denyAsSource).toBeDefined(); + expect(r.denyAsTarget).toBeDefined(); + }); + + it("getRulesById throws NotFoundException for unknown id", () => { + expect(() => service.getRulesById("Foo")).toThrow(NotFoundException); + }); +}); diff --git a/apps/server/src/node-types/node-types.service.ts b/apps/server/src/node-types/node-types.service.ts new file mode 100644 index 0000000..3c7a2a5 --- /dev/null +++ b/apps/server/src/node-types/node-types.service.ts @@ -0,0 +1,78 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { zodV3ToOpenAPI } from "nestjs-zod"; +import { NODE_TYPE_REGISTRY, type NodeTypeMetadata, type FieldHint } from "./registry"; +import type { NodeKind } from "../nodes/schemas"; +import { RulesEngine } from "../rules/rules.engine"; + +export interface NodeTypeSummary { + id: NodeKind; + family: string; + familyLabel: string; + description: string; + nameKey: string; +} + +export interface NodeTypeDetail extends NodeTypeSummary { + schema: unknown; // OpenAPI/JSON Schema export + fieldHints: Record; // UI inspector rozet/grup metadata +} + +export interface NodeTypeRules { + id: NodeKind; + allowAsSource: Array; + allowAsTarget: Array; + denyAsSource: Array; + denyAsTarget: Array; +} + +@Injectable() +export class NodeTypesService { + constructor(private readonly rulesEngine: RulesEngine) {} + + listAll(): NodeTypeSummary[] { + return Object.values(NODE_TYPE_REGISTRY).map((m) => this.toSummary(m)); + } + + getById(id: string): NodeTypeDetail { + const meta = this.find(id); + return { + ...this.toSummary(meta), + // zodV3ToOpenAPI recursive type sig stresses TS compiler — OK at runtime + schema: zodV3ToOpenAPI(meta.schema as any), + fieldHints: meta.fieldHints ?? {}, + }; + } + + getRulesById(id: string): NodeTypeRules { + const meta = this.find(id); + const r = this.rulesEngine.rulesForNodeKind(meta.id); + return { + id: meta.id, + allowAsSource: r.allowAsSource, + allowAsTarget: r.allowAsTarget, + denyAsSource: r.denyAsSource, + denyAsTarget: r.denyAsTarget, + }; + } + + private find(id: string): NodeTypeMetadata { + const meta = NODE_TYPE_REGISTRY[id as NodeKind]; + if (!meta) { + throw new NotFoundException({ + code: "ERR_NODE_TYPE_NOT_FOUND", + message: `Node tipi '${id}' bilinmiyor. Mevcut tipler: ${Object.keys(NODE_TYPE_REGISTRY).join(", ")}.`, + }); + } + return meta; + } + + private toSummary(m: NodeTypeMetadata): NodeTypeSummary { + return { + id: m.id, + family: m.family, + familyLabel: m.familyLabel, + description: m.description, + nameKey: m.nameKey, + }; + } +} diff --git a/apps/server/src/node-types/registry.ts b/apps/server/src/node-types/registry.ts new file mode 100644 index 0000000..526bf95 --- /dev/null +++ b/apps/server/src/node-types/registry.ts @@ -0,0 +1,297 @@ +import type { z } from "zod"; +import { + TableNodeSchema, DTONodeSchema, ModelNodeSchema, EnumNodeSchema, ViewNodeSchema, + ServiceNodeSchema, WorkerNodeSchema, EventHandlerNodeSchema, + ControllerNodeSchema, MessageQueueNodeSchema, + RepositoryNodeSchema, CacheNodeSchema, ExternalServiceNodeSchema, + FrontendAppNodeSchema, UIComponentNodeSchema, + MiddlewareNodeSchema, + EnvironmentVariableNodeSchema, ExceptionNodeSchema, + ModuleNodeSchema, + APIGatewayNodeSchema, + OrchestratorNodeSchema, + type NodeKind, +} from "../nodes/schemas"; + +/** Field hint for UI inspector: badge + group + value-set ref + node-ref. */ +export interface FieldHint { + badge?: string; + group?: string; + /** Strict enum reference — id defined in value-sets registry (e.g. 'http-methods'). */ + valueSet?: string; + /** In-project node reference — frontend opens NodeRefCombobox (autocomplete + + * create new). When edgeKind set: auto edge after select/create + * (e.g. Service.Throws → THROWS). */ + nodeRef?: { + type: NodeKind; + edgeKind?: string; + }; +} + +export interface NodeTypeMetadata { + id: NodeKind; + family: NodeFamily; + familyLabel: string; + description: string; + nameKey: string; + schema: z.ZodTypeAny; + /** Dotted property path -> hint. For column/field badges in UI. */ + fieldHints?: Record; +} + +export type NodeFamily = + | "data" + | "business" + | "access" + | "infrastructure" + | "client" + | "security" + | "configuration" + | "structure"; + +export const FAMILY_LABELS: Record = { + data: "Data and Schema", + business: "Business Logic and Service", + access: "Access and Presentation", + infrastructure: "Infrastructure and Data Access", + client: "Client", + security: "Security and Policy", + configuration: "Configuration and Environment", + structure: "Containers (Bounded Context)", +}; + +const make = ( + id: NodeKind, + family: NodeFamily, + nameKey: string, + description: string, + schema: z.ZodTypeAny, +): NodeTypeMetadata => ({ + id, + family, + familyLabel: FAMILY_LABELS[family], + description, + nameKey, + schema, +}); + +export const NODE_TYPE_REGISTRY: Record = { + Table: make("Table", "data", "TableName", + "Physical properties of a database table — columns, indexes, foreign keys.", TableNodeSchema), + DTO: make("DTO", "data", "Name", + "Data transfer object. Contains no business logic; used for transfer with validation rules.", DTONodeSchema), + Model: make("Model", "data", "ClassName", + "The main data class (entity) used in business logic.", ModelNodeSchema), + Enum: make("Enum", "data", "Name", + "A set of constant values. For statuses, types, etc.", EnumNodeSchema), + View: make("View", "data", "ViewName", + "A database view or materialized view. SQL/aggregate definition + source tables.", ViewNodeSchema), + + Service: make("Service", "business", "ServiceName", + "Core business logic. Defines its methods + transaction scope.", ServiceNodeSchema), + Worker: make("Worker", "business", "WorkerName", + "A scheduled (cron) task. Schedule + retry policy + timeout.", WorkerNodeSchema), + EventHandler: make("EventHandler", "business", "HandlerName", + "An event listener. Sync/async event processing.", EventHandlerNodeSchema), + + Controller: make("Controller", "access", "ControllerName", + "HTTP API endpoints. BaseRoute + endpoint list (method, route, auth).", ControllerNodeSchema), + MessageQueue: make("MessageQueue", "access", "QueueName", + "A message queue or pub/sub topic. Provider: RabbitMQ/Kafka/AWS SQS/Generic.", MessageQueueNodeSchema), + + Repository: make("Repository", "infrastructure", "RepositoryName", + "Data access layer (DAO). Manages an Entity, defines custom queries.", RepositoryNodeSchema), + Cache: make("Cache", "infrastructure", "CacheName", + "Memory/cache. Key pattern + TTL + engine (Redis/Memcached/Memory).", CacheNodeSchema), + ExternalService: make("ExternalService", "infrastructure", "ServiceName", + "External API integration. BaseURL + auth type + timeout.", ExternalServiceNodeSchema), + + FrontendApp: make("FrontendApp", "client", "AppName", + "Frontend app. Framework (React/Vue/...) + deployment type (SPA/SSR/SSG).", FrontendAppNodeSchema), + UIComponent: make("UIComponent", "client", "ComponentName", + "UI component. Props (external data) + State (internal variables) definition.", UIComponentNodeSchema), + + Middleware: make("Middleware", "security", "MiddlewareName", + "Pipeline middleware. Scope (Global/SpecificRoutes) + execution order.", MiddlewareNodeSchema), + + EnvironmentVariable: make("EnvironmentVariable", "configuration", "Key", + "Environment variable. DataType + IsSecret + which environments it is active in.", EnvironmentVariableNodeSchema), + Exception: make("Exception", "configuration", "ExceptionName", + "Custom exception type. HTTP status code + log severity.", ExceptionNodeSchema), + + Module: make("Module", "structure", "ModuleName", + "Bounded Context / Module. StrictBoundaries limits external access.", ModuleNodeSchema), + + APIGateway: make("APIGateway", "access", "GatewayName", + "API gateway / load balancer. Provider (Kong/Nginx/AWS_API_Gateway/...). Required for the Rules Matrix.", APIGatewayNodeSchema), + Orchestrator: make("Orchestrator", "business", "OrchestratorName", + "Business process coordinator. Pattern (Saga/CompensatingTransaction/StateMachine). Coordinates multiple Services.", OrchestratorNodeSchema), +}; + +/* ── Phase A fieldHints — UI inspector badge/group metadata ─────────────── */ +NODE_TYPE_REGISTRY.Table.fieldHints = { + "Columns.DataType": { badge: "TYPE", group: "definition", valueSet: "column-data-types" }, + "Columns.IsPrimaryKey": { badge: "PK", group: "constraints" }, + "Columns.IsNotNull": { badge: "NN", group: "constraints" }, + "Columns.IsUnique": { badge: "UQ", group: "constraints" }, + "Columns.AutoIncrement": { badge: "AI", group: "constraints" }, + "Columns.EnumRef": { badge: "ENUM", group: "reference", nodeRef: { type: "Enum", edgeKind: "USES" } }, + PrimaryKey: { badge: "PK", group: "constraints" }, + ForeignKeys: { badge: "FK", group: "constraints" }, + "ForeignKeys.OnDelete": { badge: "DEL", group: "constraints", valueSet: "on-delete-actions" }, + "ForeignKeys.OnUpdate": { badge: "UPD", group: "constraints", valueSet: "on-delete-actions" }, + UniqueConstraints: { badge: "UQ", group: "constraints" }, + CheckConstraints: { badge: "CHK", group: "constraints" }, + Indexes: { badge: "IDX", group: "performance" }, +}; +NODE_TYPE_REGISTRY.DTO.fieldHints = { + "Fields.DataType": { badge: "TYPE", group: "definition", valueSet: "parameter-types" }, + "Fields.IsRequired": { badge: "REQ", group: "validation" }, + "Fields.ValidationRules": { badge: "VALID", group: "validation" }, + "Fields.ValidationRules.Rule": { badge: "RULE", group: "validation", valueSet: "validation-rules" }, + "Fields.NestedDTORef": { badge: "DTO", group: "reference", nodeRef: { type: "DTO", edgeKind: "HAS" } }, + "Fields.EnumRef": { badge: "ENUM", group: "reference", nodeRef: { type: "Enum", edgeKind: "USES" } }, +}; +NODE_TYPE_REGISTRY.Model.fieldHints = { + "Properties.Type": { badge: "TYPE", group: "definition", valueSet: "parameter-types" }, + "Properties.RelationType": { badge: "REL", group: "relations", valueSet: "relation-types" }, + "Properties.RelatedModelRef": { badge: "REF", group: "relations", nodeRef: { type: "Model", edgeKind: "HAS" } }, + "Methods.Visibility": { badge: "VIS", group: "behavior", valueSet: "visibility" }, + "Methods.ReturnType": { badge: "RET", group: "behavior", valueSet: "parameter-types" }, + "Methods.Parameters.Type": { badge: "TYPE", group: "behavior", valueSet: "parameter-types" }, + TableRef: { badge: "TABLE", group: "reference", nodeRef: { type: "Table", edgeKind: "USES" } }, + Methods: { badge: "FN", group: "behavior" }, +}; +NODE_TYPE_REGISTRY.Enum.fieldHints = { + BackingType: { badge: "TYPE", group: "definition", valueSet: "enum-backing-types" }, + Values: { badge: "ENUM", group: "definition" }, +}; +NODE_TYPE_REGISTRY.View.fieldHints = { + Materialized: { badge: "MAT", group: "definition" }, + RefreshStrategy: { badge: "REFRESH", group: "definition", valueSet: "view-refresh-strategy" }, + SourceTables: { badge: "SRC", group: "reference" }, + "Columns.DataType": { badge: "TYPE", group: "definition", valueSet: "column-data-types" }, +}; + +/* ── Phase B fieldHints — Business Logic + Access ──────────────────────────── */ +NODE_TYPE_REGISTRY.Service.fieldHints = { + IsTransactionScoped: { badge: "TX", group: "behavior" }, + "Methods.Visibility": { badge: "VIS", group: "behavior", valueSet: "visibility" }, + "Methods.ReturnType": { badge: "RET", group: "behavior", valueSet: "parameter-types" }, + "Methods.Parameters.Type": { badge: "TYPE", group: "behavior", valueSet: "parameter-types" }, + "Methods.Parameters.DtoRef": { badge: "DTO", group: "reference", nodeRef: { type: "DTO", edgeKind: "USES" } }, + "Methods.IsAsync": { badge: "ASYNC", group: "behavior" }, + "Methods.Throws": { badge: "THROWS", group: "behavior", nodeRef: { type: "Exception", edgeKind: "THROWS" } }, + "Methods.ReturnDtoRef": { badge: "DTO", group: "reference", nodeRef: { type: "DTO", edgeKind: "RETURNS" } }, + Dependencies: { badge: "DI", group: "relations" }, + "Dependencies.Kind": { badge: "KIND", group: "relations", valueSet: "service-dep-kinds" }, + // Dependencies.Ref dynamic by kind (Repository/Service/Cache/ExternalService). + // Generic for now — frontend can dynamic lookup, edgeKind CALLS default. +}; +NODE_TYPE_REGISTRY.EventHandler.fieldHints = { + ...NODE_TYPE_REGISTRY.EventHandler.fieldHints, + QueueRef: { badge: "QUEUE", group: "reference", nodeRef: { type: "MessageQueue", edgeKind: "SUBSCRIBES" } }, +}; +NODE_TYPE_REGISTRY.Orchestrator.fieldHints = { + ...NODE_TYPE_REGISTRY.Orchestrator.fieldHints, + "Steps.ServiceRef": { badge: "SVC", group: "relations", nodeRef: { type: "Service", edgeKind: "CALLS" } }, +}; +NODE_TYPE_REGISTRY.Worker.fieldHints = { + Schedule: { badge: "CRON", group: "scheduling" }, + RetryPolicy: { badge: "RETRY", group: "reliability" }, + Concurrency: { badge: "CONC", group: "scheduling" }, + IsEnabled: { badge: "ON", group: "scheduling" }, +}; +NODE_TYPE_REGISTRY.EventHandler.fieldHints = { + EventName: { badge: "EVENT", group: "definition" }, + IsAsync: { badge: "ASYNC", group: "behavior" }, + QueueRef: { badge: "QUEUE", group: "reference" }, + RetryPolicy: { badge: "RETRY", group: "reliability" }, + DeadLetterQueue: { badge: "DLQ", group: "reliability" }, +}; +NODE_TYPE_REGISTRY.Orchestrator.fieldHints = { + Pattern: { badge: "PATTERN", group: "definition" }, + "Steps.ServiceRef": { badge: "SVC", group: "relations" }, + "Steps.CompensationAction": { badge: "COMP", group: "reliability" }, +}; +NODE_TYPE_REGISTRY.Controller.fieldHints = { + "Endpoints.Method": { badge: "HTTP", group: "routing", valueSet: "http-methods" }, + "Endpoints.HttpMethod": { badge: "HTTP", group: "routing", valueSet: "http-methods" }, + "Endpoints.StatusCode": { badge: "STATUS", group: "routing", valueSet: "http-status" }, + "Endpoints.StatusCodes.Code": { badge: "HTTP", group: "definition", valueSet: "http-status" }, + "Endpoints.SuccessStatus": { badge: "STATUS", group: "routing", valueSet: "http-status" }, + "Endpoints.RequiresAuth": { badge: "AUTH", group: "security" }, + "Endpoints.RequestDTORef": { badge: "REQ", group: "reference", nodeRef: { type: "DTO", edgeKind: "USES" } }, + "Endpoints.ResponseDTORef": { badge: "RES", group: "reference", nodeRef: { type: "DTO", edgeKind: "RETURNS" } }, + "Endpoints.RateLimit": { badge: "RATE", group: "security" }, + // MiddlewareRefs: no edgeKind — Controller→Middleware USES forbidden in whitelist. + // Semantic direction Middleware→Controller ROUTES_TO (reverse); autocomplete used, no edge created. + "Endpoints.MiddlewareRefs": { badge: "MW", group: "security", nodeRef: { type: "Middleware" } }, +}; +NODE_TYPE_REGISTRY.MessageQueue.fieldHints = { + Type: { badge: "TYPE", group: "definition" }, + Provider: { badge: "PROV", group: "definition" }, + Protocol: { badge: "PROTO", group: "definition", valueSet: "protocols" }, + DeliveryGuarantee: { badge: "QOS", group: "reliability" }, + DeadLetterQueue: { badge: "DLQ", group: "reliability" }, + MessageFormat: { badge: "DTO", group: "reference", nodeRef: { type: "DTO", edgeKind: "USES" } }, +}; +NODE_TYPE_REGISTRY.APIGateway.fieldHints = { + Provider: { badge: "PROV", group: "definition" }, + AuthMode: { badge: "AUTH", group: "security" }, + CorsEnabled: { badge: "CORS", group: "security" }, + "Routes.Method": { badge: "HTTP", group: "routing", valueSet: "http-methods" }, + "Routes.TargetRef": { badge: "TARGET", group: "relations", nodeRef: { type: "Controller", edgeKind: "ROUTES_TO" } }, +}; + +/* ── Phase C fieldHints — Infra/Client/Security/Config/Structure ──────────── */ +NODE_TYPE_REGISTRY.Repository.fieldHints = { + EntityReference: { badge: "ENTITY", group: "reference", nodeRef: { type: "Model", edgeKind: "USES" } }, + IsCached: { badge: "CACHED", group: "performance" }, + "CustomQueries.QueryType": { badge: "QUERY", group: "behavior" }, +}; +NODE_TYPE_REGISTRY.Cache.fieldHints = { + Engine: { badge: "ENGINE", group: "definition" }, + TTL_Seconds: { badge: "TTL", group: "expiry" }, + EvictionPolicy: { badge: "EVICT", group: "expiry" }, +}; +NODE_TYPE_REGISTRY.ExternalService.fieldHints = { + AuthType: { badge: "AUTH", group: "security" }, + Protocol: { badge: "PROTO", group: "definition", valueSet: "protocols" }, + CircuitBreaker: { badge: "CB", group: "reliability" }, + RetryPolicy: { badge: "RETRY", group: "reliability" }, + RateLimit: { badge: "RATE", group: "reliability" }, +}; +NODE_TYPE_REGISTRY.FrontendApp.fieldHints = { + Framework: { badge: "FW", group: "definition" }, + DeploymentType: { badge: "DEPLOY", group: "definition" }, + StateManagement: { badge: "STATE", group: "definition" }, + "Routes.ComponentRef": { badge: "CMP", group: "relations", nodeRef: { type: "UIComponent", edgeKind: "HAS" } }, +}; +NODE_TYPE_REGISTRY.UIComponent.fieldHints = { + "Props.Type": { badge: "TYPE", group: "interface", valueSet: "parameter-types" }, + "Props.Required": { badge: "REQ", group: "interface" }, + Events: { badge: "EVENT", group: "interface" }, + ChildComponentRefs: { badge: "CHILD", group: "relations", nodeRef: { type: "UIComponent", edgeKind: "HAS" } }, +}; +NODE_TYPE_REGISTRY.Middleware.fieldHints = { + AppliesTo: { badge: "SCOPE", group: "definition", valueSet: "middleware-scope" }, + ExecutionOrder: { badge: "ORDER", group: "definition" }, + MiddlewareType: { badge: "TYPE", group: "definition", valueSet: "middleware-types" }, +}; +NODE_TYPE_REGISTRY.EnvironmentVariable.fieldHints = { + IsSecret: { badge: "SECRET", group: "security" }, + IsRequired: { badge: "REQ", group: "validation" }, + DataType: { badge: "TYPE", group: "definition", valueSet: "primitive-types" }, +}; +NODE_TYPE_REGISTRY.Exception.fieldHints = { + HttpStatusCode: { badge: "HTTP", group: "definition", valueSet: "http-status" }, + LogSeverity: { badge: "SEV", group: "definition" }, + ErrorCode: { badge: "CODE", group: "definition" }, + ParentExceptionRef: { badge: "EXTENDS", group: "relations", nodeRef: { type: "Exception", edgeKind: "EXTENDS" } }, +}; +NODE_TYPE_REGISTRY.Module.fieldHints = { + StrictBoundaries: { badge: "STRICT", group: "definition" }, + ExposedServices: { badge: "EXPOSE", group: "relations", nodeRef: { type: "Service", edgeKind: "USES" } }, + Dependencies: { badge: "DEP", group: "relations", nodeRef: { type: "Module", edgeKind: "DEPENDS_ON" } }, +}; diff --git a/apps/server/src/nodes/dto/create-node.dto.ts b/apps/server/src/nodes/dto/create-node.dto.ts new file mode 100644 index 0000000..41f4e52 --- /dev/null +++ b/apps/server/src/nodes/dto/create-node.dto.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import { createZodDto } from "nestjs-zod"; +import { PositionSchema } from "../schemas/base.schema"; +import { TableNodeSchema } from "../schemas/table.schema"; +import { DTONodeSchema } from "../schemas/dto.schema"; +import { ModelNodeSchema } from "../schemas/model.schema"; +import { EnumNodeSchema } from "../schemas/enum.schema"; +import { ViewNodeSchema } from "../schemas/view.schema"; +import { ServiceNodeSchema } from "../schemas/service.schema"; +import { WorkerNodeSchema } from "../schemas/worker.schema"; +import { EventHandlerNodeSchema } from "../schemas/event-handler.schema"; +import { ControllerNodeSchema } from "../schemas/controller.schema"; +import { MessageQueueNodeSchema } from "../schemas/message-queue.schema"; +import { RepositoryNodeSchema } from "../schemas/repository.schema"; +import { CacheNodeSchema } from "../schemas/cache.schema"; +import { ExternalServiceNodeSchema } from "../schemas/external-service.schema"; +import { FrontendAppNodeSchema } from "../schemas/frontend-app.schema"; +import { UIComponentNodeSchema } from "../schemas/ui-component.schema"; +import { MiddlewareNodeSchema } from "../schemas/middleware.schema"; +import { EnvironmentVariableNodeSchema } from "../schemas/env-variable.schema"; +import { ExceptionNodeSchema } from "../schemas/exception.schema"; +import { ModuleNodeSchema } from "../schemas/module.schema"; +import { APIGatewayNodeSchema } from "../schemas/api-gateway.schema"; +import { OrchestratorNodeSchema } from "../schemas/orchestrator.schema"; + +// id/createdAt/updatedAt generated server-side — client does not send. +const CreatableBaseFields = { + projectId: z.string().uuid(), + position: PositionSchema, + homeTabId: z.string().uuid().optional(), // project default tab when omitted +}; + +const make = (kind: K, propertiesSchema: z.ZodTypeAny) => + z.object({ + ...CreatableBaseFields, + type: z.literal(kind), + properties: propertiesSchema, + }).strict(); + +export const CreateNodeSchema = z.discriminatedUnion("type", [ + make("Table", TableNodeSchema.shape.properties), + make("DTO", DTONodeSchema.shape.properties), + make("Model", ModelNodeSchema.shape.properties), + make("Enum", EnumNodeSchema.shape.properties), + make("View", ViewNodeSchema.shape.properties), + make("Service", ServiceNodeSchema.shape.properties), + make("Worker", WorkerNodeSchema.shape.properties), + make("EventHandler", EventHandlerNodeSchema.shape.properties), + make("Controller", ControllerNodeSchema.shape.properties), + make("MessageQueue", MessageQueueNodeSchema.shape.properties), + make("Repository", RepositoryNodeSchema.shape.properties), + make("Cache", CacheNodeSchema.shape.properties), + make("ExternalService", ExternalServiceNodeSchema.shape.properties), + make("FrontendApp", FrontendAppNodeSchema.shape.properties), + make("UIComponent", UIComponentNodeSchema.shape.properties), + make("Middleware", MiddlewareNodeSchema.shape.properties), + make("EnvironmentVariable", EnvironmentVariableNodeSchema.shape.properties), + make("Exception", ExceptionNodeSchema.shape.properties), + make("Module", ModuleNodeSchema.shape.properties), + make("APIGateway", APIGatewayNodeSchema.shape.properties), + make("Orchestrator", OrchestratorNodeSchema.shape.properties), +]); + +export type CreateNodeInput = z.infer; + +// discriminated union does not match createZodDto type signature — works at runtime +export class CreateNodeDto extends createZodDto(CreateNodeSchema as any) {} diff --git a/apps/server/src/nodes/dto/node-response.dto.ts b/apps/server/src/nodes/dto/node-response.dto.ts new file mode 100644 index 0000000..ba686bc --- /dev/null +++ b/apps/server/src/nodes/dto/node-response.dto.ts @@ -0,0 +1,5 @@ +import type { Node } from "../schemas"; +import type { SuccessEnvelope } from "../../common/envelope"; + +export type NodeResponse = SuccessEnvelope; +export type NodeListResponse = SuccessEnvelope<{ nodes: Node[]; total: number }>; diff --git a/apps/server/src/nodes/dto/update-node.dto.ts b/apps/server/src/nodes/dto/update-node.dto.ts new file mode 100644 index 0000000..992793d --- /dev/null +++ b/apps/server/src/nodes/dto/update-node.dto.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; +import { createZodDto } from "nestjs-zod"; +import { PositionSchema } from "../schemas/base.schema"; + +// `type` optional string — schema accepts, service rejects with ERR_KIND_IMMUTABLE. +// So user sees semantically correct error code per plan. +export const UpdateNodeSchema = z.object({ + position: PositionSchema.optional(), + properties: z.record(z.unknown()).optional(), + type: z.string().optional(), + // Optimistic concurrency: client's last seen version. When omitted, legacy + // last-write-wins (backward compatible). When set, mismatch -> 409. + expectedVersion: z.number().int().nonnegative().optional(), +}).strict(); + +export type UpdateNodeInput = z.infer; + +export class UpdateNodeDto extends createZodDto(UpdateNodeSchema) {} diff --git a/apps/server/src/nodes/nodes.controller.spec.ts b/apps/server/src/nodes/nodes.controller.spec.ts new file mode 100644 index 0000000..dc13b07 --- /dev/null +++ b/apps/server/src/nodes/nodes.controller.spec.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from "vitest"; +import { NodesController } from "./nodes.controller"; +import { NodesService } from "./nodes.service"; + +const projectId = "550e8400-e29b-41d4-a716-446655440001"; +const validTablePayload = { + type: "Table", + projectId, + position: { x: 0, y: 0 }, + properties: { + TableName: "users", + Description: "u", + Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }], + }, +}; + +describe("NodesController.create", () => { + it("calls service.create with URL projectId and returns envelope", async () => { + const service = { create: vi.fn(async (_p, input) => ({ ...input, id: "x", createdAt: "t", updatedAt: "t" })) }; + const controller = new NodesController(service as unknown as NodesService); + const result = await controller.create(projectId, validTablePayload as any); + expect(service.create).toHaveBeenCalledWith(projectId, validTablePayload); + expect(result.success).toBe(true); + expect(result.data.id).toBe("x"); + }); +}); + +describe("NodesController.getById", () => { + it("calls service.getById and returns envelope", async () => { + const service = { getById: vi.fn(async () => ({ id: "x", type: "Table" })) }; + const controller = new NodesController(service as unknown as NodesService); + const result = await controller.getById("p", "x"); + expect(service.getById).toHaveBeenCalledWith("p", "x"); + expect(result.success).toBe(true); + }); +}); + +describe("NodesController.list", () => { + it("calls with type filter", async () => { + const service = { list: vi.fn(async () => [{ id: "x", type: "Table" }]) }; + const controller = new NodesController(service as unknown as NodesService); + const result = await controller.list("p", "Table"); + expect(service.list).toHaveBeenCalledWith("p", "Table"); + expect(result.success).toBe(true); + expect(result.data.total).toBe(1); + }); + + it("calls without type filter", async () => { + const service = { list: vi.fn(async () => []) }; + const controller = new NodesController(service as unknown as NodesService); + const result = await controller.list("p", undefined); + expect(service.list).toHaveBeenCalledWith("p", undefined); + expect(result.data.total).toBe(0); + }); +}); + +describe("NodesController.update", () => { + it("position update", async () => { + const service = { update: vi.fn(async () => ({ id: "x", type: "Table" })) }; + const controller = new NodesController(service as unknown as NodesService); + const result = await controller.update("p", "x", { position: { x: 1, y: 2 } } as any); + expect(service.update).toHaveBeenCalledWith("p", "x", { position: { x: 1, y: 2 } }); + expect(result.success).toBe(true); + }); +}); + +describe("NodesController.delete", () => { + it("calls service.delete", async () => { + const service = { delete: vi.fn(async () => undefined) }; + const controller = new NodesController(service as unknown as NodesService); + await controller.delete("p", "x"); + expect(service.delete).toHaveBeenCalledWith("p", "x"); + }); +}); diff --git a/apps/server/src/nodes/nodes.controller.ts b/apps/server/src/nodes/nodes.controller.ts new file mode 100644 index 0000000..ffa9f7b --- /dev/null +++ b/apps/server/src/nodes/nodes.controller.ts @@ -0,0 +1,108 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Query, UseGuards } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiParam, ApiQuery, ApiResponse } from "@nestjs/swagger"; +import { ProjectAccessGuard } from "../auth/project-access.guard"; +import { NodesService } from "./nodes.service"; +import { CreateNodeDto } from "./dto/create-node.dto"; +import { UpdateNodeDto } from "./dto/update-node.dto"; +import { ok } from "../common/envelope"; +import type { NodeResponse, NodeListResponse } from "./dto/node-response.dto"; +import { NODE_KINDS, type NodeKind } from "./schemas"; + +// All 21 NodeKinds — old list only included Data family (5 types), so queries like +// ?type=Exception were silently ignored for other types. +const KIND_VALUES: readonly NodeKind[] = NODE_KINDS; + +@ApiTags("Nodes") +@UseGuards(ProjectAccessGuard) +@Controller("projects/:projectId/nodes") +export class NodesController { + constructor(private readonly service: NodesService) {} + + @Post() + @HttpCode(201) + @ApiOperation({ + summary: "Create a new node", + description: + "Adds a new building block to the project. Based on the body's `type` (kind) discriminator, the **kind-specific `properties`** are validated with Zod. If `id`/timestamp are not provided the server generates them. The project must exist (strict integrity) and `*Name` must be unique within the project.", + }) + @ApiParam({ name: "projectId", description: "UUID of the project the node belongs to" }) + @ApiResponse({ status: 201, description: "Node created — returns the full node object." }) + @ApiResponse({ status: 400, description: "`ERR_SCHEMA_INVALID` (schema) or `ERR_PROJECT_MISMATCH` (URL ≠ body projectId)." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND` — create the project first." }) + @ApiResponse({ status: 409, description: "`ERR_ID_CONFLICT` (id exists) or `ERR_NAME_DUPLICATE` (*Name collision)." }) + async create( + @Param("projectId") projectId: string, + @Body() body: CreateNodeDto, + ): Promise { + const created = await this.service.create(projectId, body as any); + return ok(created); + } + + @Get() + @ApiOperation({ + summary: "List nodes", + description: "Returns the nodes in the project. Can be filtered to a single kind with the `?type=Table` query.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiQuery({ name: "type", required: false, description: "Node kind filter (e.g. `Table`, `Service`). Invalid values are ignored.", example: "Service" }) + @ApiResponse({ status: 200, description: "`data: { nodes: [...], total }`." }) + async list( + @Param("projectId") projectId: string, + @Query("type") type: string | undefined, + ): Promise { + const kind = type && KIND_VALUES.includes(type as NodeKind) ? (type as NodeKind) : undefined; + const nodes = await this.service.list(projectId, kind); + return ok({ nodes, total: nodes.length }); + } + + @Get(":nodeId") + @ApiOperation({ summary: "Single node", description: "Returns the given node with its full properties." }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiParam({ name: "nodeId", description: "Node UUID" }) + @ApiResponse({ status: 200, description: "Full node object." }) + @ApiResponse({ status: 404, description: "`ERR_NODE_NOT_FOUND`." }) + async getById( + @Param("projectId") projectId: string, + @Param("nodeId") nodeId: string, + ): Promise { + const node = await this.service.getById(projectId, nodeId); + return ok(node); + } + + @Patch(":nodeId") + @ApiOperation({ + summary: "Update node (field-level replace)", + description: + "`position` and/or `properties` are replaced with the full given object (no deep merge). `type` (kind) is **immutable** — if attempted, `ERR_KIND_IMMUTABLE`.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiParam({ name: "nodeId", description: "Node UUID" }) + @ApiResponse({ status: 200, description: "Updated node (`updatedAt` is refreshed)." }) + @ApiResponse({ status: 400, description: "`ERR_SCHEMA_INVALID` or `ERR_KIND_IMMUTABLE`." }) + @ApiResponse({ status: 404, description: "`ERR_NODE_NOT_FOUND`." }) + async update( + @Param("projectId") projectId: string, + @Param("nodeId") nodeId: string, + @Body() body: UpdateNodeDto, + ): Promise { + const updated = await this.service.update(projectId, nodeId, body as any); + return ok(updated); + } + + @Delete(":nodeId") + @HttpCode(204) + @ApiOperation({ + summary: "Delete node", + description: "Deletes the node and its attached edges (DETACH). Not idempotent — 404 if it does not exist.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiParam({ name: "nodeId", description: "Node UUID" }) + @ApiResponse({ status: 204, description: "Deleted (no body)." }) + @ApiResponse({ status: 404, description: "`ERR_NODE_NOT_FOUND`." }) + async delete( + @Param("projectId") projectId: string, + @Param("nodeId") nodeId: string, + ): Promise { + await this.service.delete(projectId, nodeId); + } +} diff --git a/apps/server/src/nodes/nodes.module.ts b/apps/server/src/nodes/nodes.module.ts new file mode 100644 index 0000000..83c08db --- /dev/null +++ b/apps/server/src/nodes/nodes.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { NodesController } from "./nodes.controller"; +import { NodesService } from "./nodes.service"; +import { NodesRepository } from "./nodes.repository"; +import { ProjectsModule } from "../projects/projects.module"; +import { TabsModule } from "../tabs/tabs.module"; + +@Module({ + imports: [ProjectsModule, TabsModule], + controllers: [NodesController], + providers: [NodesService, NodesRepository], + exports: [NodesRepository, NodesService], +}) +export class NodesModule {} diff --git a/apps/server/src/nodes/nodes.repository.spec.ts b/apps/server/src/nodes/nodes.repository.spec.ts new file mode 100644 index 0000000..e74acf7 --- /dev/null +++ b/apps/server/src/nodes/nodes.repository.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { Neo4jContainer, StartedNeo4jContainer } from "@testcontainers/neo4j"; +import { Neo4jService } from "../neo4j/neo4j.service"; +import { NodesRepository, type StoredNode } from "./nodes.repository"; + +const projectId = "550e8400-e29b-41d4-a716-446655440001"; +const nodeFixture = (overrides: Partial = {}): StoredNode => ({ + id: "550e8400-e29b-41d4-a716-446655440000", + type: "Table", + projectId, + positionX: 100, + positionY: 200, + homeTabId: "550e8400-e29b-41d4-a716-4466554400aa", + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", + version: 1, + properties: { TableName: "users", Description: "u", Columns: [], Indexes: [] }, + ...overrides, +}); + +describe("NodesRepository", () => { + let container: StartedNeo4jContainer; + let neo4j: Neo4jService; + let repo: NodesRepository; + + beforeAll(async () => { + container = await new Neo4jContainer("neo4j:5-community").withApoc().start(); + neo4j = new Neo4jService({ + uri: container.getBoltUri(), + user: container.getUsername(), + password: container.getPassword(), + }); + await neo4j.onModuleInit(); + await neo4j.run("CREATE CONSTRAINT node_id_unique IF NOT EXISTS FOR (n:Node) REQUIRE n.id IS UNIQUE"); + await neo4j.run("CREATE INDEX node_project_idx IF NOT EXISTS FOR (n:Node) ON (n.projectId)"); + repo = new NodesRepository(neo4j); + }, 180_000); + + afterAll(async () => { + await neo4j.onModuleDestroy(); + await container.stop(); + }); + + beforeEach(async () => { + await neo4j.run("MATCH (n:Node) DETACH DELETE n"); + }); + + it("create + getById reads node back", async () => { + await repo.create(nodeFixture()); + const got = await repo.getById(projectId, "550e8400-e29b-41d4-a716-446655440000"); + expect(got?.type).toBe("Table"); + expect(got?.properties).toEqual({ TableName: "users", Description: "u", Columns: [], Indexes: [] }); + }); + +it("getById otherwise returns null", async () => { + const got = await repo.getById(projectId, "00000000-0000-0000-0000-000000000000"); + expect(got).toBeNull(); + }); + + it("list returns all nodes in project", async () => { + await repo.create(nodeFixture({ id: "550e8400-e29b-41d4-a716-446655440002" })); + await repo.create(nodeFixture({ id: "550e8400-e29b-41d4-a716-446655440003", type: "DTO", properties: { Name: "X", Description: "d", Fields: [] } })); + const list = await repo.list(projectId); + expect(list).toHaveLength(2); + }); + + it("list type filter works", async () => { + await repo.create(nodeFixture({ id: "550e8400-e29b-41d4-a716-446655440002" })); + await repo.create(nodeFixture({ id: "550e8400-e29b-41d4-a716-446655440003", type: "DTO", properties: { Name: "X", Description: "d", Fields: [] } })); + const list = await repo.list(projectId, "Table"); + expect(list).toHaveLength(1); + expect(list[0].type).toBe("Table"); + }); + + it("update replaces position and properties", async () => { + await repo.create(nodeFixture()); + await repo.update(projectId, "550e8400-e29b-41d4-a716-446655440000", { + positionX: 999, + positionY: 888, + properties: { TableName: "renamed", Description: "x", Columns: [], Indexes: [] }, + updatedAt: "2026-05-21T11:00:00.000Z", + }); + const got = await repo.getById(projectId, "550e8400-e29b-41d4-a716-446655440000"); + expect(got?.positionX).toBe(999); + expect((got?.properties as any).TableName).toBe("renamed"); + expect(got?.updatedAt).toBe("2026-05-21T11:00:00.000Z"); + }); + + it("create starts with version=1", async () => { + await repo.create(nodeFixture()); + const got = await repo.getById(projectId, "550e8400-e29b-41d4-a716-446655440000"); + expect(got?.version).toBe(1); + }); + + it("update increments version by +1 (no expectedVersion — backward compat)", async () => { + await repo.create(nodeFixture()); + const updated = await repo.update(projectId, "550e8400-e29b-41d4-a716-446655440000", { + positionX: 5, updatedAt: "2026-05-21T11:00:00.000Z", + }); + expect(updated?.version).toBe(2); + }); + + it("update with correct expectedVersion succeeds and increments version", async () => { + await repo.create(nodeFixture()); + const updated = await repo.update(projectId, "550e8400-e29b-41d4-a716-446655440000", { + positionX: 5, updatedAt: "2026-05-21T11:00:00.000Z", expectedVersion: 1, + }); + expect(updated?.version).toBe(2); + }); + + it("stale expectedVersion -> null (atomic guard prevents lost update)", async () => { + await repo.create(nodeFixture()); + // first update bumps version to 2 + await repo.update(projectId, "550e8400-e29b-41d4-a716-446655440000", { + positionX: 5, updatedAt: "2026-05-21T11:00:00.000Z", expectedVersion: 1, + }); + // second update still expects version=1 -> rejected (0 rows) + const stale = await repo.update(projectId, "550e8400-e29b-41d4-a716-446655440000", { + positionX: 9, updatedAt: "2026-05-21T12:00:00.000Z", expectedVersion: 1, + }); + expect(stale).toBeNull(); + // data stays at first update (second did not overwrite) + const got = await repo.getById(projectId, "550e8400-e29b-41d4-a716-446655440000"); + expect(got?.positionX).toBe(5); + expect(got?.version).toBe(2); + }); + + it("delete causes getById to return null for removed node", async () => { + await repo.create(nodeFixture()); + await repo.delete(projectId, "550e8400-e29b-41d4-a716-446655440000"); + const got = await repo.getById(projectId, "550e8400-e29b-41d4-a716-446655440000"); + expect(got).toBeNull(); + }); + + it("findByName used for in-project unique check", async () => { + await repo.create(nodeFixture()); + const found = await repo.findByName(projectId, "users"); + expect(found?.id).toBe("550e8400-e29b-41d4-a716-446655440000"); + const notFound = await repo.findByName(projectId, "ghost"); + expect(notFound).toBeNull(); + }); +}); diff --git a/apps/server/src/nodes/nodes.repository.ts b/apps/server/src/nodes/nodes.repository.ts new file mode 100644 index 0000000..73db5d7 --- /dev/null +++ b/apps/server/src/nodes/nodes.repository.ts @@ -0,0 +1,186 @@ +import { Injectable } from "@nestjs/common"; +import { Neo4jService } from "../neo4j/neo4j.service"; +import type { NodeKind } from "./schemas"; + +export interface StoredNode { + id: string; + type: NodeKind; + projectId: string; + positionX: number; + positionY: number; + homeTabId: string; + createdAt: string; + updatedAt: string; + version: number; + properties: Record; +} + +export interface NodeUpdate { + positionX?: number; + positionY?: number; + properties?: Record; + updatedAt: string; + /** Optimistic concurrency: when given, only updates node at this version + * (atomic). Mismatch returns 0 rows (TOCTOU race backstop). */ + expectedVersion?: number; +} + +const NAME_KEYS_BY_KIND: Record = { + // Data + Table: "TableName", + DTO: "Name", + Model: "ClassName", + Enum: "Name", + View: "ViewName", + // Business Logic + Service: "ServiceName", + Worker: "WorkerName", + EventHandler: "HandlerName", + // Access + Controller: "ControllerName", + MessageQueue: "QueueName", + // Infrastructure + Repository: "RepositoryName", + Cache: "CacheName", + ExternalService: "ServiceName", + // Client + FrontendApp: "AppName", + UIComponent: "ComponentName", + // Security + Middleware: "MiddlewareName", + // Configuration + EnvironmentVariable: "Key", + Exception: "ExceptionName", + // Structure + Module: "ModuleName", + // Phase 2A additional types + APIGateway: "GatewayName", + Orchestrator: "OrchestratorName", +}; + +@Injectable() +export class NodesRepository { + constructor(private readonly neo4j: Neo4jService) {} + + async create(node: StoredNode): Promise { + const cypher = ` + CREATE (n:Node:${node.type} { + id: $id, projectId: $projectId, + positionX: $positionX, positionY: $positionY, homeTabId: $homeTabId, + createdAt: datetime($createdAt), updatedAt: datetime($updatedAt), + version: 1, + properties: $properties + }) + `; + await this.neo4j.run(cypher, { + id: node.id, + projectId: node.projectId, + positionX: node.positionX, + positionY: node.positionY, + homeTabId: node.homeTabId, + createdAt: node.createdAt, + updatedAt: node.updatedAt, + properties: JSON.stringify(node.properties), + }); + } + + async getById(projectId: string, id: string): Promise { + const result = await this.neo4j.run( + `MATCH (n:Node {id: $id, projectId: $projectId}) RETURN n, labels(n) AS labels`, + { id, projectId }, + ); + if (result.records.length === 0) return null; + return toStoredNode(result.records[0].get("n"), result.records[0].get("labels")); + } + + async list(projectId: string, kind?: NodeKind): Promise { + const cypher = kind + ? `MATCH (n:Node:${kind} {projectId: $projectId}) RETURN n, labels(n) AS labels` + : `MATCH (n:Node {projectId: $projectId}) RETURN n, labels(n) AS labels`; + const result = await this.neo4j.run(cypher, { projectId }); + return result.records.map((r) => toStoredNode(r.get("n"), r.get("labels"))); + } + + async update(projectId: string, id: string, update: NodeUpdate): Promise { + const partial: Record = {}; + if (update.positionX !== undefined) partial.positionX = update.positionX; + if (update.positionY !== undefined) partial.positionY = update.positionY; + if (update.properties !== undefined) partial.properties = JSON.stringify(update.properties); + + // When expectedVersion is given, atomic guard: update only if version matches. + // coalesce(n.version,1): treat pre-migration nodes without version as 1 (matches frontend default). + // Each successful update increments version by +1. 0 rows -> not found OR version mismatch (service distinguishes). + const result = await this.neo4j.run( + `MATCH (n:Node {id: $id, projectId: $projectId}) + WHERE $expectedVersion IS NULL OR coalesce(n.version, 1) = $expectedVersion + SET n += $partial, n.updatedAt = datetime($updatedAt), n.version = coalesce(n.version, 1) + 1 + RETURN n, labels(n) AS labels`, + { id, projectId, partial, updatedAt: update.updatedAt, expectedVersion: update.expectedVersion ?? null }, + ); + if (result.records.length === 0) return null; + return toStoredNode(result.records[0].get("n"), result.records[0].get("labels")); + } + + async delete(projectId: string, id: string): Promise { + const result = await this.neo4j.run( + `MATCH (n:Node {id: $id, projectId: $projectId}) + WITH n + DETACH DELETE n + RETURN 1 AS deleted`, + { id, projectId }, + ); + return result.records.length > 0; + } + + async findByName(projectId: string, name: string): Promise { + // Walk all name field variants with OR — globally unique within project. + const result = await this.neo4j.run( + `MATCH (n:Node {projectId: $projectId}) + WITH n, apoc.convert.fromJsonMap(n.properties) AS props + WHERE props.TableName = $name + OR props.Name = $name + OR props.ClassName = $name + OR props.ViewName = $name + OR props.ServiceName = $name + OR props.WorkerName = $name + OR props.HandlerName = $name + OR props.ControllerName = $name + OR props.QueueName = $name + OR props.RepositoryName = $name + OR props.CacheName = $name + OR props.AppName = $name + OR props.ComponentName = $name + OR props.MiddlewareName = $name + OR props.Key = $name + OR props.ExceptionName = $name + OR props.ModuleName = $name + OR props.GatewayName = $name + OR props.OrchestratorName = $name + RETURN n, labels(n) AS labels LIMIT 1`, + { projectId, name }, + ); + if (result.records.length === 0) return null; + return toStoredNode(result.records[0].get("n"), result.records[0].get("labels")); + } + + findNameKey(kind: NodeKind): string { + return NAME_KEYS_BY_KIND[kind]; + } +} + +function toStoredNode(n: any, labels: string[]): StoredNode { + const props = n.properties; + const kind = labels.find((l: string) => l !== "Node") as NodeKind; + return { + id: props.id, + type: kind, + projectId: props.projectId, + positionX: Number(props.positionX), + positionY: Number(props.positionY), + homeTabId: props.homeTabId, + createdAt: new Date(props.createdAt).toISOString(), + updatedAt: new Date(props.updatedAt).toISOString(), + version: Number(props.version ?? 1), + properties: JSON.parse(props.properties), + }; +} diff --git a/apps/server/src/nodes/nodes.service.spec.ts b/apps/server/src/nodes/nodes.service.spec.ts new file mode 100644 index 0000000..b775ae5 --- /dev/null +++ b/apps/server/src/nodes/nodes.service.spec.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ConflictException, NotFoundException, BadRequestException } from "@nestjs/common"; +import { NodesService } from "./nodes.service"; +import type { StoredNode } from "./nodes.repository"; + +function makeRepo(initial: StoredNode[] = []) { + const store = new Map(initial.map((n) => [n.id, n])); + return { + create: vi.fn(async (n: StoredNode) => { store.set(n.id, n); }), + getById: vi.fn(async (_p: string, id: string) => store.get(id) ?? null), + list: vi.fn(async (p: string, k?: string) => Array.from(store.values()).filter((n) => n.projectId === p && (!k || n.type === k))), + update: vi.fn(async (p: string, id: string, upd: any) => { + const existing = store.get(id); + if (!existing) return null; + // atomic version guard simulation (same semantics as repo) + if (upd.expectedVersion !== undefined && (existing.version ?? 1) !== upd.expectedVersion) return null; + const next = { ...existing }; + if (upd.positionX !== undefined) next.positionX = upd.positionX; + if (upd.positionY !== undefined) next.positionY = upd.positionY; + if (upd.properties !== undefined) next.properties = upd.properties; + next.updatedAt = upd.updatedAt; + next.version = (existing.version ?? 1) + 1; + store.set(id, next); + return next; + }), + delete: vi.fn(async (_p: string, id: string) => store.delete(id)), + findByName: vi.fn(async (p: string, name: string) => { + for (const n of store.values()) { + if (n.projectId !== p) continue; + const props = n.properties as Record; + if (props.TableName === name || props.Name === name || props.ClassName === name || props.ViewName === name) return n; + } + return null; + }), + findNameKey: vi.fn((kind: string) => kind === "Table" ? "TableName" : kind === "Model" ? "ClassName" : kind === "View" ? "ViewName" : "Name"), + }; +} + +const projectsRepoMock = { exists: vi.fn(async () => true), bumpRevision: vi.fn(async () => 1) }; +const tabsMock = { ensureDefault: vi.fn(async () => ({ id: "550e8400-e29b-41d4-a716-4466554400aa" })) }; + +const projectId = "550e8400-e29b-41d4-a716-446655440001"; +const validTable = { + id: "550e8400-e29b-41d4-a716-446655440000", + type: "Table" as const, + projectId, + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", + properties: { TableName: "users", Description: "u", Columns: [{ Name: "id", DataType: "UUID" as const, IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }] }, +}; + +describe("NodesService.create", () => { + let repo: ReturnType; + let service: NodesService; + + beforeEach(() => { + repo = makeRepo(); + service = new NodesService(repo as any, projectsRepoMock as any, tabsMock as any); + }); + + it("throws BadRequestException when URL projectId does not match body projectId", async () => { + await expect(service.create("other-project", validTable as any)).rejects.toBeInstanceOf(BadRequestException); + }); + + it("server generates id when not provided", async () => { + const { id, ...noId } = validTable; + const result = await service.create(projectId, noId as any); + expect(result.id).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("server generates createdAt/updatedAt when not provided", async () => { + const { createdAt, updatedAt, ...rest } = validTable; + const result = await service.create(projectId, rest as any); + expect(result.createdAt).toBeDefined(); + expect(result.updatedAt).toBeDefined(); + }); + + it("ERR_ID_CONFLICT when same id already exists", async () => { + repo = makeRepo([{ id: validTable.id, type: "Table", projectId, positionX: 0, positionY: 0, createdAt: "x", updatedAt: "x", properties: {} }]); + service = new NodesService(repo as any, projectsRepoMock as any, tabsMock as any); + await expect(service.create(projectId, validTable as any)) + .rejects.toMatchObject({ response: { code: "ERR_ID_CONFLICT" } }); + }); + + it("ERR_NAME_DUPLICATE when same name exists", async () => { + repo = makeRepo([{ id: "550e8400-e29b-41d4-a716-446655440099", type: "Table", projectId, positionX: 0, positionY: 0, createdAt: "x", updatedAt: "x", properties: { TableName: "users" } }]); + service = new NodesService(repo as any, projectsRepoMock as any, tabsMock as any); + const { id, ...noId } = validTable; + await expect(service.create(projectId, noId as any)) + .rejects.toMatchObject({ response: { code: "ERR_NAME_DUPLICATE" } }); + }); +}); + +describe("NodesService.update", () => { + let repo: ReturnType; + let service: NodesService; + + beforeEach(() => { + repo = makeRepo([{ + id: validTable.id, type: "Table", projectId, + positionX: 0, positionY: 0, + createdAt: validTable.createdAt, updatedAt: validTable.updatedAt, + version: 1, + properties: validTable.properties, + }]); + service = new NodesService(repo as any, projectsRepoMock as any, tabsMock as any); + }); + + it("NotFoundException when missing", async () => { + await expect(service.update(projectId, "00000000-0000-0000-0000-000000000000", { position: { x: 1, y: 1 } })) + .rejects.toBeInstanceOf(NotFoundException); + }); + + it("ERR_KIND_IMMUTABLE when attempting to change type", async () => { + await expect(service.update(projectId, validTable.id, { type: "DTO" } as any)) + .rejects.toMatchObject({ response: { code: "ERR_KIND_IMMUTABLE" } }); + }); + + it("position update sets updatedAt", async () => { + const result = await service.update(projectId, validTable.id, { position: { x: 99, y: 88 } }); + expect(result.position.x).toBe(99); + expect(result.updatedAt).not.toBe(validTable.updatedAt); + }); + + it("ERR_VERSION_CONFLICT when expectedVersion mismatches (prevents lost update)", async () => { + await expect(service.update(projectId, validTable.id, { position: { x: 1, y: 1 }, expectedVersion: 99 })) + .rejects.toMatchObject({ response: { code: "ERR_VERSION_CONFLICT" } }); + }); + + it("update with correct expectedVersion succeeds and increments version", async () => { + const result = await service.update(projectId, validTable.id, { position: { x: 7, y: 7 }, expectedVersion: 1 }); + expect(result.position.x).toBe(7); + expect(result.version).toBe(2); + }); + + it("update without expectedVersion succeeds (backward compat) and increments version", async () => { + const result = await service.update(projectId, validTable.id, { position: { x: 3, y: 3 } }); + expect(result.version).toBe(2); + }); +}); + +describe("NodesService.delete", () => { + it("NotFoundException when missing", async () => { + const repo = makeRepo(); + const service = new NodesService(repo as any, projectsRepoMock as any, tabsMock as any); + await expect(service.delete(projectId, "00000000-0000-0000-0000-000000000000")) + .rejects.toBeInstanceOf(NotFoundException); + }); + + it("deletes when present", async () => { + const repo = makeRepo([{ + id: validTable.id, type: "Table", projectId, + positionX: 0, positionY: 0, + createdAt: validTable.createdAt, updatedAt: validTable.updatedAt, + properties: validTable.properties, + }]); + const service = new NodesService(repo as any, projectsRepoMock as any, tabsMock as any); + await expect(service.delete(projectId, validTable.id)).resolves.toBeUndefined(); + }); +}); diff --git a/apps/server/src/nodes/nodes.service.ts b/apps/server/src/nodes/nodes.service.ts new file mode 100644 index 0000000..82f6519 --- /dev/null +++ b/apps/server/src/nodes/nodes.service.ts @@ -0,0 +1,243 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { randomUUID } from "node:crypto"; +import type { Node, NodeKind } from "./schemas"; +import { assertNoPlaintextSecret, redactNodeSecrets } from "./secret-redaction"; +import { validateNodeProperties } from "./validate-properties"; +import { NodesRepository, type StoredNode } from "./nodes.repository"; +import { ProjectsRepository } from "../projects/projects.repository"; +import { TabsService } from "../tabs/tabs.service"; + +type CreateInput = Omit & { + id?: string; + createdAt?: string; + updatedAt?: string; + homeTabId?: string; +}; + +export interface UpdateInput { + position?: { x: number; y: number }; + properties?: Record; + type?: NodeKind; + /** Optimistic concurrency — client's last seen version. Mismatch yields 409. */ + expectedVersion?: number; +} + +@Injectable() +export class NodesService { + constructor( + private readonly repo: NodesRepository, + private readonly projectsRepo: ProjectsRepository, + private readonly tabs: TabsService, + ) {} + + async create(urlProjectId: string, input: CreateInput): Promise { + if (input.projectId !== urlProjectId) { + throw new BadRequestException({ + code: "ERR_PROJECT_MISMATCH", + message: "The projectId in the URL does not match the projectId in the body.", + }); + } + + // Strict referential integrity — project must exist + if (!(await this.projectsRepo.exists(urlProjectId))) { + throw new NotFoundException({ + code: "ERR_PROJECT_NOT_FOUND", + message: `Project '${urlProjectId}' not found. Create a project first via POST /api/v1/projects.`, + }); + } + + // Kind-based schema validation (defaults applied, extras rejected). + // HTTP DTO already does this; AI create_node calls this service directly, + // so required here too -> invalid AI output self-corrects via ERR_SCHEMA_INVALID. + const validatedProps = validateNodeProperties(input.type, input.properties); + // Security: block storing plaintext secret values in env-var (all paths). + assertNoPlaintextSecret(input.type, validatedProps); + + const id = input.id ?? randomUUID(); + const now = new Date().toISOString(); + const createdAt = input.createdAt ?? now; + const updatedAt = input.updatedAt ?? now; + + if (input.id) { + const existing = await this.repo.getById(urlProjectId, input.id); + if (existing) { + throw new ConflictException({ + code: "ERR_ID_CONFLICT", + message: `id '${input.id}' is already in use.`, + }); + } + } + + const nameKey = this.repo.findNameKey(input.type); + const name = validatedProps[nameKey] as string | undefined; + if (name) { + const collision = await this.repo.findByName(urlProjectId, name); + if (collision) { + throw new ConflictException({ + code: "ERR_NAME_DUPLICATE", + message: `The name '${name}' is already in use in this project.`, + }); + } + } + + const homeTabId = input.homeTabId ?? (await this.tabs.ensureDefault(urlProjectId)).id; + + const stored: StoredNode = { + id, + type: input.type, + projectId: urlProjectId, + positionX: input.position.x, + positionY: input.position.y, + homeTabId, + createdAt, + updatedAt, + version: 1, + properties: validatedProps, + }; + await this.repo.create(stored); + await this.projectsRepo.bumpRevision(urlProjectId); + return this.toNode(stored); + } + + async getById(projectId: string, id: string): Promise { + const stored = await this.repo.getById(projectId, id); + if (!stored) { + throw new NotFoundException({ + code: "ERR_NODE_NOT_FOUND", + message: `Node '${id}' not found.`, + }); + } + return this.toNode(stored); + } + + async list(projectId: string, kind?: NodeKind): Promise { + const stored = await this.repo.list(projectId, kind); + return stored.map((s) => this.toNode(s)); + } + + async update(projectId: string, id: string, input: UpdateInput): Promise { + if (input.type !== undefined) { + throw new BadRequestException({ + code: "ERR_KIND_IMMUTABLE", + message: "The node type cannot be changed.", + }); + } + const existing = await this.repo.getById(projectId, id); + if (!existing) { + throw new NotFoundException({ + code: "ERR_NODE_NOT_FOUND", + message: `Node '${id}' not found.`, + }); + } + + // Optimistic concurrency — early/clean error. (Actual guarantee is atomic in repo.) + if (input.expectedVersion !== undefined && existing.version !== input.expectedVersion) { + throw new ConflictException({ + code: "ERR_VERSION_CONFLICT", + message: "This node was modified by someone else (human or AI) in the meantime. The latest version has been reloaded — please reapply your changes.", + currentVersion: existing.version, + }); + } + + if (input.properties) { + // Kind-based schema validation — PATCH body arrives as z.record(unknown). + // properties is replaced wholesale, so incoming FULL properties must be valid; + // parsed + defaulted here; invalid/missing/extra fields rejected. + input.properties = validateNodeProperties(existing.type, input.properties); + // Security: must be secret-safe. + assertNoPlaintextSecret(existing.type, input.properties); + const nameKey = this.repo.findNameKey(existing.type); + const newName = input.properties[nameKey] as string | undefined; + const oldName = (existing.properties as Record)[nameKey] as string | undefined; + if (newName && newName !== oldName) { + const collision = await this.repo.findByName(projectId, newName); + if (collision && collision.id !== id) { + throw new ConflictException({ + code: "ERR_NAME_DUPLICATE", + message: `The name '${newName}' is already in use in this project.`, + }); + } + } + } + + const updatedAt = new Date().toISOString(); + const updated = await this.repo.update(projectId, id, { + positionX: input.position?.x, + positionY: input.position?.y, + properties: input.properties, + updatedAt, + expectedVersion: input.expectedVersion, + }); + if (!updated) { + // Atomic guard returned 0 rows. If expectedVersion was given + node still exists + // -> another update slipped in (TOCTOU race) = version conflict; otherwise deleted. + if (input.expectedVersion !== undefined) { + const stillThere = await this.repo.getById(projectId, id); + if (stillThere) { + throw new ConflictException({ + code: "ERR_VERSION_CONFLICT", + message: "This node was modified by someone else (human or AI) in the meantime. The latest version has been reloaded — please reapply your changes.", + currentVersion: stillThere.version, + }); + } + } + throw new NotFoundException({ + code: "ERR_NODE_NOT_FOUND", + message: `Node '${id}' not found.`, + }); + } + // Structural change (properties) bumps revision; position-only moves do not + // (avoids unnecessary push conflicts from drift). + if (input.properties) await this.projectsRepo.bumpRevision(projectId); + return this.toNode(updated); + } + + async delete(projectId: string, id: string): Promise { + const deleted = await this.repo.delete(projectId, id); + if (!deleted) { + throw new NotFoundException({ + code: "ERR_NODE_NOT_FOUND", + message: `Node '${id}' not found.`, + }); + } + await this.projectsRepo.bumpRevision(projectId); + } + + /** Partial properties patch — shallow-merge over existing (RAW) properties, + * delegates to update() with fresh version (full schema validation + secret + + * name collision + revision bump happen there). Array fields (Columns/ + * Endpoints/Methods/Fields) are REPLACED; caller must send the full array. */ + async applyPropertiesPatch( + projectId: string, + id: string, + patch: Record, + ): Promise { + const existing = await this.repo.getById(projectId, id); + if (!existing) { + throw new NotFoundException({ + code: "ERR_NODE_NOT_FOUND", + message: `Node '${id}' not found.`, + }); + } + const merged = { ...(existing.properties as Record), ...patch }; + return this.update(projectId, id, { properties: merged, expectedVersion: existing.version }); + } + + private toNode(s: StoredNode): Node { + return { + id: s.id, + type: s.type, + projectId: s.projectId, + position: { x: s.positionX, y: s.positionY }, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + version: s.version, + properties: redactNodeSecrets(s.type, s.properties), + } as Node; + } +} diff --git a/apps/server/src/nodes/schemas/api-gateway.schema.spec.ts b/apps/server/src/nodes/schemas/api-gateway.schema.spec.ts new file mode 100644 index 0000000..efd4eb9 --- /dev/null +++ b/apps/server/src/nodes/schemas/api-gateway.schema.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { APIGatewayNodeSchema } from "./api-gateway.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-22T10:30:00.000Z", + updatedAt: "2026-05-22T10:30:00.000Z", +}; + +const validProperties = { + GatewayName: "MainGateway", + Description: "Main entry gateway", + Provider: "Kong" as const, +}; + +const parse = (properties: unknown) => + APIGatewayNodeSchema.parse({ ...validBase, type: "APIGateway", properties }); + +describe("APIGatewayNodeSchema (enriched)", () => { + it("parses valid APIGateway (Routes default empty)", () => { + const node = parse(validProperties); + expect(node.properties.Provider).toBe("Kong"); + expect(node.properties.Routes).toEqual([]); + }); + + it("accepts AuthMode + CorsEnabled + Routes", () => { + const node = parse({ + ...validProperties, + AuthMode: "JWT", + CorsEnabled: true, + Routes: [{ Path: "/users", TargetRef: "UserController", Methods: ["GET", "POST"], AuthRequired: true, RateLimit: { Requests: 50, WindowSeconds: 60 } }], + }); + expect(node.properties.AuthMode).toBe("JWT"); + expect(node.properties.Routes[0].Methods).toEqual(["GET", "POST"]); + expect(node.properties.Routes[0].AuthRequired).toBe(true); + }); + + it("rejects invalid AuthMode", () => { + expect(() => parse({ ...validProperties, AuthMode: "Basic" })).toThrow(); + }); + + it("throws when Route Methods is empty", () => { + expect(() => parse({ ...validProperties, Routes: [{ Path: "/x", TargetRef: "C", Methods: [] }] })).toThrow(); + }); + + it("Description zorunlu", () => { + const { Description, ...rest } = validProperties; + expect(() => parse(rest)).toThrow(); + }); + + it("rejects unknown Provider", () => { + expect(() => parse({ ...validProperties, Provider: "Apigee" })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/api-gateway.schema.ts b/apps/server/src/nodes/schemas/api-gateway.schema.ts new file mode 100644 index 0000000..c4e8379 --- /dev/null +++ b/apps/server/src/nodes/schemas/api-gateway.schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +const RouteSchema = z.object({ + Path: z.string().min(1), + TargetRef: z.string().min(1).describe("→ Controller veya Service node Name"), + Methods: z.array(z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"])).min(1), + AuthRequired: z.boolean().default(false), + RateLimit: z.object({ Requests: z.number().int().positive(), WindowSeconds: z.number().int().positive() }).optional(), +}).strict(); + +export const APIGatewayNodeSchema = BaseNodeSchema.extend({ + type: z.literal("APIGateway"), + properties: z.object({ + GatewayName: z.string().min(1), + Description: z.string().min(1), + Provider: z.enum(["Kong", "Nginx", "AWS_API_Gateway", "Azure_API_Management", "Generic"]), + AuthMode: z.enum(["None", "JWT", "OAuth2", "ApiKey"]).optional(), + CorsEnabled: z.boolean().optional(), + Routes: z.array(RouteSchema).default([]), + }).strict(), +}).strict(); + +export type APIGatewayNode = z.infer; diff --git a/apps/server/src/nodes/schemas/base.schema.spec.ts b/apps/server/src/nodes/schemas/base.schema.spec.ts new file mode 100644 index 0000000..1f0256e --- /dev/null +++ b/apps/server/src/nodes/schemas/base.schema.spec.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { BaseNodeSchema, PositionSchema } from "./base.schema"; + +describe("PositionSchema", () => { + it("parses valid position", () => { + const result = PositionSchema.parse({ x: 150, y: 300 }); + expect(result).toEqual({ x: 150, y: 300 }); + }); + + it("throws when x or y is missing", () => { + expect(() => PositionSchema.parse({ x: 150 })).toThrow(); + }); + + it("throws when x or y is not a number", () => { + expect(() => PositionSchema.parse({ x: "150", y: 300 })).toThrow(); + }); +}); + +describe("BaseNodeSchema", () => { + const valid = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", + }; + + it("parses valid base node", () => { + expect(() => BaseNodeSchema.parse(valid)).not.toThrow(); + }); + + it("throws when id is not a UUID", () => { + expect(() => BaseNodeSchema.parse({ ...valid, id: "abc" })).toThrow(); + }); + + it("throws when createdAt is not ISO datetime", () => { + expect(() => BaseNodeSchema.parse({ ...valid, createdAt: "yesterday" })).toThrow(); + }); + + it("throws when projectId is not a UUID", () => { + expect(() => BaseNodeSchema.parse({ ...valid, projectId: "p1" })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/base.schema.ts b/apps/server/src/nodes/schemas/base.schema.ts new file mode 100644 index 0000000..7d03b65 --- /dev/null +++ b/apps/server/src/nodes/schemas/base.schema.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +export const PositionSchema = z.object({ + x: z.number(), + y: z.number(), +}); + +export const BaseNodeSchema = z.object({ + id: z.string().uuid(), + projectId: z.string().uuid(), + position: PositionSchema, + homeTabId: z.string().uuid().optional(), // node's home tab + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + // Optimistic concurrency — +1 on each successful update. default(1): legacy/parsed + // objects (fixture, seed, pre-migration nodes) must not break. + version: z.number().int().nonnegative().default(1), + // Implementation counters (written by CLI `status --report` / VS Code extension). + // NOT inside properties — top-level meta like position, without touching strict + // kind schemas. Canvas badge is omitted when absent (never reported). + implTotal: z.number().int().nonnegative().optional(), + implFilled: z.number().int().nonnegative().optional(), + implAi: z.number().int().nonnegative().optional(), +}); + +export type BaseNode = z.infer; +export type Position = z.infer; diff --git a/apps/server/src/nodes/schemas/cache.schema.spec.ts b/apps/server/src/nodes/schemas/cache.schema.spec.ts new file mode 100644 index 0000000..0b53295 --- /dev/null +++ b/apps/server/src/nodes/schemas/cache.schema.spec.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { CacheNodeSchema } from "./cache.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + CacheName: "UserSessionCache", + Description: "Active session cache", + KeyPattern: "user:session:{userId}", + TTL_Seconds: 3600, + Engine: "Redis" as const, +}; + +const parse = (properties: unknown) => + CacheNodeSchema.parse({ ...validBase, type: "Cache", properties }); + +describe("CacheNodeSchema (enriched)", () => { + it("parses valid Cache", () => { + expect(parse(validProperties).properties.Engine).toBe("Redis"); + }); + + it("accepts EvictionPolicy + MaxSizeMB + Serialization", () => { + const node = parse({ ...validProperties, EvictionPolicy: "LRU", MaxSizeMB: 256, Serialization: "json" }); + expect(node.properties.EvictionPolicy).toBe("LRU"); + expect(node.properties.MaxSizeMB).toBe(256); + }); + + it("rejects invalid EvictionPolicy", () => { + expect(() => parse({ ...validProperties, EvictionPolicy: "Random" })).toThrow(); + }); + + it("rejects unknown Engine", () => { + expect(() => parse({ ...validProperties, Engine: "Hazelcast" })).toThrow(); + }); + + it("TTL_Seconds must be positive", () => { + expect(() => parse({ ...validProperties, TTL_Seconds: 0 })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/cache.schema.ts b/apps/server/src/nodes/schemas/cache.schema.ts new file mode 100644 index 0000000..825259a --- /dev/null +++ b/apps/server/src/nodes/schemas/cache.schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +export const CacheNodeSchema = BaseNodeSchema.extend({ + type: z.literal("Cache"), + properties: z.object({ + CacheName: z.string().min(1), + Description: z.string().min(1), + KeyPattern: z.string().min(1), + TTL_Seconds: z.number().int().positive(), + Engine: z.enum(["Redis", "Memcached", "Memory"]), + EvictionPolicy: z.enum(["LRU", "LFU", "FIFO", "TTL"]).optional(), + MaxSizeMB: z.number().int().positive().optional(), + Serialization: z.enum(["json", "binary", "string"]).optional(), + }).strict(), +}).strict(); + +export type CacheNode = z.infer; diff --git a/apps/server/src/nodes/schemas/controller.schema.spec.ts b/apps/server/src/nodes/schemas/controller.schema.spec.ts new file mode 100644 index 0000000..cb77745 --- /dev/null +++ b/apps/server/src/nodes/schemas/controller.schema.spec.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { ControllerNodeSchema } from "./controller.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + ControllerName: "UserController", + Description: "User API", + BaseRoute: "/api/v1/users", + Version: "v1", + Endpoints: [ + { + HttpMethod: "POST", + Route: "/register", + RequestDTORef: "RegisterUserRequestDTO", + ResponseDTORef: "UserResponseDTO", + RequiresAuth: false, + }, + ], +}; + +const parse = (properties: unknown) => + ControllerNodeSchema.parse({ ...validBase, type: "Controller", properties }); + +describe("ControllerNodeSchema (enriched)", () => { + it("parses valid Controller", () => { + const node = parse(validProperties); + expect(node.properties.Endpoints[0].HttpMethod).toBe("POST"); + expect(node.properties.Endpoints[0].RequestDTORef).toBe("RegisterUserRequestDTO"); + expect(node.properties.Version).toBe("v1"); + }); + + it("endpoint default arrays (RequiredRoles/PathParams/QueryParams/...)", () => { + const ep = parse(validProperties).properties.Endpoints[0]; + expect(ep.RequiredRoles).toEqual([]); + expect(ep.PathParams).toEqual([]); + expect(ep.QueryParams).toEqual([]); + expect(ep.StatusCodes).toEqual([]); + expect(ep.MiddlewareRefs).toEqual([]); + }); + + it("accepts rich endpoint (path/query params + status + rate limit + middleware)", () => { + const node = parse({ + ...validProperties, + Endpoints: [{ + HttpMethod: "GET", + Route: "/:id", + RequiresAuth: true, + PathParams: [{ Name: "id", Type: "UUID" }], + QueryParams: [{ Name: "expand", Type: "string", Required: false }], + StatusCodes: [{ Code: 200, Description: "OK" }, { Code: 404 }], + MiddlewareRefs: ["AuthMiddleware"], + RateLimit: { Requests: 100, WindowSeconds: 60 }, + }], + }); + const ep = node.properties.Endpoints[0]; + expect(ep.PathParams[0].Name).toBe("id"); + expect(ep.QueryParams[0].Required).toBe(false); + expect(ep.RateLimit?.Requests).toBe(100); + expect(ep.MiddlewareRefs).toEqual(["AuthMiddleware"]); + }); + + it("rejects legacy RequestDTO/ResponseDTO field (strict)", () => { + expect(() => parse({ + ...validProperties, + Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: false, RequestDTO: "X" }], + })).toThrow(); + }); + + it("Description is required", () => { + const { Description, ...rest } = validProperties; + expect(() => parse(rest)).toThrow(); + }); + + it("throws when Endpoints is empty", () => { + expect(() => parse({ ...validProperties, Endpoints: [] })).toThrow(); + }); + + it("rejects unknown HttpMethod", () => { + expect(() => parse({ ...validProperties, Endpoints: [{ HttpMethod: "FETCH", Route: "/", RequiresAuth: false }] })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/controller.schema.ts b/apps/server/src/nodes/schemas/controller.schema.ts new file mode 100644 index 0000000..781d335 --- /dev/null +++ b/apps/server/src/nodes/schemas/controller.schema.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +const ParamSchema = z.object({ + Name: z.string().min(1), + Type: z.string().min(1), +}).strict(); + +const QueryParamSchema = z.object({ + Name: z.string().min(1), + Type: z.string().min(1), + Required: z.boolean().default(false), +}).strict(); + +const EndpointSchema = z.object({ + HttpMethod: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]), + Route: z.string().min(1), + RequestDTORef: z.string().optional().describe("→ DTO node Name"), + ResponseDTORef: z.string().optional().describe("→ DTO node Name"), + ReturnsCollection: z + .boolean() + .optional() + .describe( + "SINGLE SOURCE of cardinality: does the endpoint return a collection? When set, " + + "the controller uses this field NOT the route-shape heuristic (DTO[] vs DTO). " + + "Same field as service.emitter -> both ends guaranteed aligned.", + ), + RequiresAuth: z.boolean(), + RequiredRoles: z.array(z.string()).default([]), + PathParams: z.array(ParamSchema).default([]), + QueryParams: z.array(QueryParamSchema).default([]), + StatusCodes: z.array(z.object({ Code: z.number().int(), Description: z.string().optional() })).default([]), + MiddlewareRefs: z.array(z.string().min(1)).default([]).describe("→ Middleware node Names"), + RateLimit: z.object({ Requests: z.number().int().positive(), WindowSeconds: z.number().int().positive() }).optional(), + Description: z.string().optional(), +}).strict(); + +export const ControllerNodeSchema = BaseNodeSchema.extend({ + type: z.literal("Controller"), + properties: z.object({ + ControllerName: z.string().min(1), + Description: z.string().min(1), + BaseRoute: z.string().min(1), + Version: z.string().optional().describe("API version, e.g. 'v1'"), + Endpoints: z.array(EndpointSchema).min(1), + }).strict(), +}).strict(); + +export type ControllerNode = z.infer; diff --git a/apps/server/src/nodes/schemas/dto.schema.spec.ts b/apps/server/src/nodes/schemas/dto.schema.spec.ts new file mode 100644 index 0000000..51ef91c --- /dev/null +++ b/apps/server/src/nodes/schemas/dto.schema.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { DTONodeSchema } from "./dto.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + Name: "CreateUserRequestDTO", + Description: "New user registration request", + Fields: [ + { Name: "email", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [{ Rule: "Email" }] }, + { Name: "age", DataType: "number", IsRequired: false, IsArray: false }, + ], +}; + +const parse = (properties: unknown) => + DTONodeSchema.parse({ ...validBase, type: "DTO", properties }); + +describe("DTONodeSchema (enriched)", () => { + it("parses valid DTO", () => { + const node = parse(validProperties); + expect(node.properties.Fields).toHaveLength(2); + }); + + it("defaults ValidationRules to empty array when omitted", () => { + const node = parse(validProperties); + expect(node.properties.Fields[1].ValidationRules).toEqual([]); + }); + + it("ValidationRule Value (MinLength) kabul eder", () => { + const node = parse({ + ...validProperties, + Fields: [{ Name: "password", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [{ Rule: "MinLength", Value: "8" }] }], + }); + expect(node.properties.Fields[0].ValidationRules[0].Value).toBe("8"); + }); + + it("rejects invalid validation rule", () => { + expect(() => parse({ + ...validProperties, + Fields: [{ Name: "x", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [{ Rule: "Bogus" }] }], + })).toThrow(); + }); + + it("accepts NestedDTORef and EnumRef", () => { + const node = parse({ + ...validProperties, + Fields: [ + { Name: "address", DataType: "object", IsRequired: true, IsArray: false, NestedDTORef: "AddressDTO" }, + { Name: "status", DataType: "string", IsRequired: true, IsArray: false, EnumRef: "OrderStatus" }, + ], + }); + expect(node.properties.Fields[0].NestedDTORef).toBe("AddressDTO"); + expect(node.properties.Fields[1].EnumRef).toBe("OrderStatus"); + }); + + it("throws when Description is missing", () => { + const { Description, ...rest } = validProperties; + expect(() => parse(rest)).toThrow(); + }); + + it("throws when Fields is empty", () => { + expect(() => parse({ ...validProperties, Fields: [] })).toThrow(); + }); + + it("throws when IsRequired is not boolean", () => { + expect(() => parse({ ...validProperties, Fields: [{ Name: "x", DataType: "string", IsRequired: "yes", IsArray: false }] })).toThrow(); + }); + + it("rejects legacy ValidationRule (string) field (strict)", () => { + expect(() => parse({ + ...validProperties, + Fields: [{ Name: "x", DataType: "string", IsRequired: true, IsArray: false, ValidationRule: "Email" }], + })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/dto.schema.ts b/apps/server/src/nodes/schemas/dto.schema.ts new file mode 100644 index 0000000..ad09c77 --- /dev/null +++ b/apps/server/src/nodes/schemas/dto.schema.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +const VALIDATION_RULES = ["Min", "Max", "MinLength", "MaxLength", "Email", "Url", "Regex", "Pattern", "Positive", "Negative"] as const; + +const ValidationRuleSchema = z.object({ + Rule: z.enum(VALIDATION_RULES), + Value: z.string().optional().describe("Min/Max/Length value or Regex pattern"), +}).strict(); + +const FieldSchema = z.object({ + Name: z.string().min(1), + DataType: z.string().min(1), + IsRequired: z.boolean(), + IsArray: z.boolean(), + ValidationRules: z.array(ValidationRuleSchema).default([]), + DefaultValue: z.string().optional(), + NestedDTORef: z.string().optional().describe("→ DTO node Name (nested DTO)"), + EnumRef: z.string().optional().describe("→ Enum node Name"), + Description: z.string().optional(), +}).strict(); + +export const DTONodeSchema = BaseNodeSchema.extend({ + type: z.literal("DTO"), + properties: z.object({ + Name: z.string().min(1), + Description: z.string().min(1), + Fields: z.array(FieldSchema).min(1), + }).strict(), +}).strict(); + +export type DTONode = z.infer; diff --git a/apps/server/src/nodes/schemas/enum.schema.spec.ts b/apps/server/src/nodes/schemas/enum.schema.spec.ts new file mode 100644 index 0000000..eea6536 --- /dev/null +++ b/apps/server/src/nodes/schemas/enum.schema.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { EnumNodeSchema } from "./enum.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + Name: "OrderStatus", + Description: "Order status", + Values: [ + { Key: "PENDING" }, + { Key: "SHIPPED", Value: "shipped", Description: "Shipped" }, + { Key: "DELIVERED" }, + ], +}; + +const parse = (properties: unknown) => + EnumNodeSchema.parse({ ...validBase, type: "Enum", properties }); + +describe("EnumNodeSchema (enriched)", () => { + it("parses valid Enum", () => { + const node = parse(validProperties); + expect(node.properties.Values).toHaveLength(3); + }); + + it("BackingType default string", () => { + const node = parse(validProperties); + expect(node.properties.BackingType).toBe("string"); + }); + + it("accepts BackingType=int", () => { + const node = parse({ ...validProperties, BackingType: "int" }); + expect(node.properties.BackingType).toBe("int"); + }); + + it("accepts key-value (Value + Description)", () => { + const node = parse(validProperties); + expect(node.properties.Values[1].Value).toBe("shipped"); + expect(node.properties.Values[1].Description).toBe("Shipped"); + }); + + it("Description zorunlu", () => { + const { Description, ...rest } = validProperties; + expect(() => parse(rest)).toThrow(); + }); + + it("throws when Values is empty", () => { + expect(() => parse({ ...validProperties, Values: [] })).toThrow(); + }); + + it("throws when Key is empty", () => { + expect(() => parse({ ...validProperties, Values: [{ Key: "" }] })).toThrow(); + }); + + it("rejects legacy string[] Values format", () => { + expect(() => parse({ ...validProperties, Values: ["PENDING", "SHIPPED"] })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/enum.schema.ts b/apps/server/src/nodes/schemas/enum.schema.ts new file mode 100644 index 0000000..b1a727b --- /dev/null +++ b/apps/server/src/nodes/schemas/enum.schema.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +export const EnumNodeSchema = BaseNodeSchema.extend({ + type: z.literal("Enum"), + properties: z.object({ + Name: z.string().min(1), + Description: z.string().min(1), + BackingType: z.enum(["string", "int"]).default("string"), + Values: z.array(z.object({ + Key: z.string().min(1), + Value: z.string().optional().describe("backing value (Key is used if absent)"), + Description: z.string().optional(), + })).min(1), + Transitions: z + .array( + z.object({ + From: z.string().min(1).describe("source state (enum member Key)"), + To: z.array(z.string().min(1)).min(1).describe("allowed target states (enum member Keys)"), + }), + ) + .optional() + .describe( + "STATE-MACHINE: allowed state transitions (From Key -> To Keys). If provided, the emitter " + + "generates a transition-map + canTransition + assertTransition guard alongside the enum; " + + "status-updating services use this guard (rejecting illegal transitions).", + ), + }).strict(), +}).strict(); + +export type EnumNode = z.infer; diff --git a/apps/server/src/nodes/schemas/env-variable.schema.spec.ts b/apps/server/src/nodes/schemas/env-variable.schema.spec.ts new file mode 100644 index 0000000..f2d9af7 --- /dev/null +++ b/apps/server/src/nodes/schemas/env-variable.schema.spec.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { EnvironmentVariableNodeSchema } from "./env-variable.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + Key: "DB_PASSWORD", + Description: "Database password", + DataType: "String" as const, + IsSecret: true, + Environment: ["Dev", "Prod"] as const, +}; + +const parse = (properties: unknown) => + EnvironmentVariableNodeSchema.parse({ ...validBase, type: "EnvironmentVariable", properties }); + +describe("EnvironmentVariableNodeSchema (enriched)", () => { + it("parses valid EnvironmentVariable (IsRequired defaults to true)", () => { + const node = parse(validProperties); + expect(node.properties.IsSecret).toBe(true); + expect(node.properties.IsRequired).toBe(true); + }); + + it("accepts DefaultValue + ValidationPattern + IsRequired=false", () => { + const node = parse({ + ...validProperties, + IsSecret: false, + DefaultValue: "5432", + ValidationPattern: "^[0-9]+$", + IsRequired: false, + }); + expect(node.properties.DefaultValue).toBe("5432"); + expect(node.properties.IsRequired).toBe(false); + }); + + it("throws when Environment is empty", () => { + expect(() => parse({ ...validProperties, Environment: [] })).toThrow(); + }); + + it("rejects unknown Environment", () => { + expect(() => parse({ ...validProperties, Environment: ["UAT"] })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/env-variable.schema.ts b/apps/server/src/nodes/schemas/env-variable.schema.ts new file mode 100644 index 0000000..abd45ce --- /dev/null +++ b/apps/server/src/nodes/schemas/env-variable.schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +export const EnvironmentVariableNodeSchema = BaseNodeSchema.extend({ + type: z.literal("EnvironmentVariable"), + properties: z.object({ + Key: z.string().min(1), + Description: z.string().min(1), + DataType: z.enum(["String", "Number", "Boolean"]), + IsSecret: z.boolean(), + Environment: z.array(z.enum(["Dev", "Staging", "Prod"])).min(1), + DefaultValue: z.string().optional(), + IsRequired: z.boolean().default(true), + ValidationPattern: z.string().optional().describe("regex validation pattern"), + }).strict(), +}).strict(); + +export type EnvironmentVariableNode = z.infer; diff --git a/apps/server/src/nodes/schemas/event-handler.schema.spec.ts b/apps/server/src/nodes/schemas/event-handler.schema.spec.ts new file mode 100644 index 0000000..c72e892 --- /dev/null +++ b/apps/server/src/nodes/schemas/event-handler.schema.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { EventHandlerNodeSchema } from "./event-handler.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + HandlerName: "UserCreatedEmailHandler", + Description: "Welcome email for new users", + EventName: "USER_CREATED", + IsAsync: true, +}; + +const parse = (properties: unknown) => + EventHandlerNodeSchema.parse({ ...validBase, type: "EventHandler", properties }); + +describe("EventHandlerNodeSchema (enriched)", () => { + it("parses valid EventHandler (optional fields empty)", () => { + const node = parse(validProperties); + expect(node.properties.EventName).toBe("USER_CREATED"); + expect(node.properties.QueueRef).toBeUndefined(); + }); + + it("accepts QueueRef + RetryPolicy + DeadLetterQueue", () => { + const node = parse({ + ...validProperties, + QueueRef: "user-events", + RetryPolicy: { MaxRetries: 5, DelaySeconds: 30 }, + DeadLetterQueue: "user-events-dlq", + }); + expect(node.properties.QueueRef).toBe("user-events"); + expect(node.properties.RetryPolicy?.MaxRetries).toBe(5); + }); + + it("RetryPolicy.MaxRetries cannot be negative", () => { + expect(() => parse({ ...validProperties, RetryPolicy: { MaxRetries: -1 } })).toThrow(); + }); + + it("Description is required", () => { + const { Description, ...rest } = validProperties; + expect(() => parse(rest)).toThrow(); + }); + + it("throws when IsAsync is not boolean", () => { + expect(() => parse({ ...validProperties, IsAsync: "yes" })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/event-handler.schema.ts b/apps/server/src/nodes/schemas/event-handler.schema.ts new file mode 100644 index 0000000..9ebff51 --- /dev/null +++ b/apps/server/src/nodes/schemas/event-handler.schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +const EHRetryPolicySchema = z.object({ + MaxRetries: z.number().int().nonnegative(), + DelaySeconds: z.number().int().nonnegative().optional(), +}).strict(); + +export const EventHandlerNodeSchema = BaseNodeSchema.extend({ + type: z.literal("EventHandler"), + properties: z.object({ + HandlerName: z.string().min(1), + Description: z.string().min(1), + EventName: z.string().min(1), + IsAsync: z.boolean(), + QueueRef: z.string().optional().describe("Subscribed → MessageQueue node Name"), + RetryPolicy: EHRetryPolicySchema.optional(), + DeadLetterQueue: z.string().optional(), + }).strict(), +}).strict(); + +export type EventHandlerNode = z.infer; diff --git a/apps/server/src/nodes/schemas/exception.schema.spec.ts b/apps/server/src/nodes/schemas/exception.schema.spec.ts new file mode 100644 index 0000000..c8a667b --- /dev/null +++ b/apps/server/src/nodes/schemas/exception.schema.spec.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { ExceptionNodeSchema } from "./exception.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + ExceptionName: "InvalidPasswordException", + Description: "Password rule violation", + HttpStatusCode: 400, + LogSeverity: "Warning" as const, +}; + +const parse = (properties: unknown) => + ExceptionNodeSchema.parse({ ...validBase, type: "Exception", properties }); + +describe("ExceptionNodeSchema (enriched)", () => { + it("parses valid Exception", () => { + expect(parse(validProperties).properties.HttpStatusCode).toBe(400); + }); + + it("accepts ErrorCode + ParentExceptionRef", () => { + const node = parse({ ...validProperties, ErrorCode: "ERR_INVALID_PASSWORD", ParentExceptionRef: "ValidationException" }); + expect(node.properties.ErrorCode).toBe("ERR_INVALID_PASSWORD"); + expect(node.properties.ParentExceptionRef).toBe("ValidationException"); + }); + + it("rejects HttpStatusCode outside 100-599 range", () => { + expect(() => parse({ ...validProperties, HttpStatusCode: 600 })).toThrow(); + }); + + it("rejects unknown LogSeverity", () => { + expect(() => parse({ ...validProperties, LogSeverity: "Trace" })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/exception.schema.ts b/apps/server/src/nodes/schemas/exception.schema.ts new file mode 100644 index 0000000..5dfdca7 --- /dev/null +++ b/apps/server/src/nodes/schemas/exception.schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +export const ExceptionNodeSchema = BaseNodeSchema.extend({ + type: z.literal("Exception"), + properties: z.object({ + ExceptionName: z.string().min(1), + Description: z.string().min(1), + HttpStatusCode: z.number().int().min(100).max(599), + LogSeverity: z.enum(["Info", "Warning", "Error", "Critical"]), + ErrorCode: z.string().optional().describe("application error code, e.g. ERR_USER_NOT_FOUND"), + ParentExceptionRef: z.string().optional().describe("inherited → Exception node Name"), + }).strict(), +}).strict(); + +export type ExceptionNode = z.infer; diff --git a/apps/server/src/nodes/schemas/external-service.schema.spec.ts b/apps/server/src/nodes/schemas/external-service.schema.spec.ts new file mode 100644 index 0000000..9d9d4e2 --- /dev/null +++ b/apps/server/src/nodes/schemas/external-service.schema.spec.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { ExternalServiceNodeSchema } from "./external-service.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + ServiceName: "StripePaymentAPI", + Description: "Stripe payment integration", + BaseURL: "https://api.stripe.com/v1", + AuthType: "Bearer" as const, + TimeoutSeconds: 30, +}; + +const parse = (properties: unknown) => + ExternalServiceNodeSchema.parse({ ...validBase, type: "ExternalService", properties }); + +describe("ExternalServiceNodeSchema (enriched)", () => { + it("parses valid ExternalService (Endpoints default empty)", () => { + const node = parse(validProperties); + expect(node.properties.AuthType).toBe("Bearer"); + expect(node.properties.Endpoints).toEqual([]); + }); + + it("accepts Endpoints + RetryPolicy + RateLimit + CircuitBreaker", () => { + const node = parse({ + ...validProperties, + Endpoints: [{ Name: "createCharge", Method: "POST", Path: "/charges" }], + RetryPolicy: { MaxRetries: 3, DelaySeconds: 2 }, + RateLimit: { Requests: 100, WindowSeconds: 60 }, + CircuitBreaker: { FailureThreshold: 5, ResetSeconds: 30 }, + }); + expect(node.properties.Endpoints[0].Method).toBe("POST"); + expect(node.properties.CircuitBreaker?.FailureThreshold).toBe(5); + }); + + it("rejects invalid BaseURL", () => { + expect(() => parse({ ...validProperties, BaseURL: "not-a-url" })).toThrow(); + }); + + it("rejects unknown AuthType", () => { + expect(() => parse({ ...validProperties, AuthType: "OAuth" })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/external-service.schema.ts b/apps/server/src/nodes/schemas/external-service.schema.ts new file mode 100644 index 0000000..8a83f0c --- /dev/null +++ b/apps/server/src/nodes/schemas/external-service.schema.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +const ExtEndpointSchema = z.object({ + Name: z.string().min(1), + Method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]), + Path: z.string().min(1), +}).strict(); + +export const ExternalServiceNodeSchema = BaseNodeSchema.extend({ + type: z.literal("ExternalService"), + properties: z.object({ + ServiceName: z.string().min(1), + Description: z.string().min(1), + BaseURL: z.string().url(), + AuthType: z.enum(["None", "Basic", "Bearer", "API_Key"]), + TimeoutSeconds: z.number().int().positive(), + Endpoints: z.array(ExtEndpointSchema).default([]), + RetryPolicy: z.object({ MaxRetries: z.number().int().nonnegative(), DelaySeconds: z.number().int().nonnegative().optional() }).optional(), + RateLimit: z.object({ Requests: z.number().int().positive(), WindowSeconds: z.number().int().positive() }).optional(), + CircuitBreaker: z.object({ FailureThreshold: z.number().int().positive(), ResetSeconds: z.number().int().positive() }).optional(), + }).strict(), +}).strict(); + +export type ExternalServiceNode = z.infer; diff --git a/apps/server/src/nodes/schemas/frontend-app.schema.spec.ts b/apps/server/src/nodes/schemas/frontend-app.schema.spec.ts new file mode 100644 index 0000000..3bcba2e --- /dev/null +++ b/apps/server/src/nodes/schemas/frontend-app.schema.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { FrontendAppNodeSchema } from "./frontend-app.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + AppName: "AdminDashboard", + Description: "Admin panel", + Framework: "React" as const, + DeploymentType: "SPA" as const, +}; + +const parse = (properties: unknown) => + FrontendAppNodeSchema.parse({ ...validBase, type: "FrontendApp", properties }); + +describe("FrontendAppNodeSchema (enriched)", () => { + it("parses valid FrontendApp (Routes default empty)", () => { + const node = parse(validProperties); + expect(node.properties.Framework).toBe("React"); + expect(node.properties.Routes).toEqual([]); + }); + + it("accepts StateManagement + StylingApproach + Routes", () => { + const node = parse({ + ...validProperties, + StateManagement: "Redux", + StylingApproach: "Tailwind", + Routes: [{ Path: "/users", ComponentRef: "UserDataTable" }], + }); + expect(node.properties.StateManagement).toBe("Redux"); + expect(node.properties.Routes[0].ComponentRef).toBe("UserDataTable"); + }); + + it("rejects invalid StateManagement", () => { + expect(() => parse({ ...validProperties, StateManagement: "MobX" })).toThrow(); + }); + + it("rejects unknown Framework", () => { + expect(() => parse({ ...validProperties, Framework: "Solid" })).toThrow(); + }); + + it("rejects unknown DeploymentType", () => { + expect(() => parse({ ...validProperties, DeploymentType: "PWA" })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/frontend-app.schema.ts b/apps/server/src/nodes/schemas/frontend-app.schema.ts new file mode 100644 index 0000000..b5d4c14 --- /dev/null +++ b/apps/server/src/nodes/schemas/frontend-app.schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +const RouteSchema = z.object({ + Path: z.string().min(1), + ComponentRef: z.string().optional().describe("→ UIComponent node Name"), +}).strict(); + +export const FrontendAppNodeSchema = BaseNodeSchema.extend({ + type: z.literal("FrontendApp"), + properties: z.object({ + AppName: z.string().min(1), + Description: z.string().min(1), + Framework: z.enum(["React", "Vue", "Angular", "Svelte", "Vanilla"]), + DeploymentType: z.enum(["SPA", "SSR", "SSG"]), + StateManagement: z.enum(["Redux", "Zustand", "Context", "Pinia", "Vuex", "NgRx", "None"]).optional(), + StylingApproach: z.enum(["CSS", "SCSS", "Tailwind", "StyledComponents", "CSSModules"]).optional(), + Routes: z.array(RouteSchema).default([]), + }).strict(), +}).strict(); + +export type FrontendAppNode = z.infer; diff --git a/apps/server/src/nodes/schemas/index.spec.ts b/apps/server/src/nodes/schemas/index.spec.ts new file mode 100644 index 0000000..c446674 --- /dev/null +++ b/apps/server/src/nodes/schemas/index.spec.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { NodeSchema, KIND_LABELS, type NodeKind } from "./index"; + +const baseFields = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +describe("NodeSchema (union)", () => { + it("parses Table type", () => { + const node = NodeSchema.parse({ + ...baseFields, type: "Table", + properties: { + TableName: "u", Description: "d", + Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }], + }, + }); + expect(node.type).toBe("Table"); + }); + + it("rejects unknown type", () => { + expect(() => NodeSchema.parse({ ...baseFields, type: "Foo", properties: {} })).toThrow(); + }); + + it("KIND_LABELS includes 5 kinds", () => { + const labels: NodeKind[] = ["Table", "DTO", "Model", "Enum", "View"]; + for (const k of labels) { + expect(KIND_LABELS[k]).toBe(k); + } + }); +}); diff --git a/apps/server/src/nodes/schemas/index.ts b/apps/server/src/nodes/schemas/index.ts new file mode 100644 index 0000000..5664461 --- /dev/null +++ b/apps/server/src/nodes/schemas/index.ts @@ -0,0 +1,184 @@ +import { z } from "zod"; +import { createZodDto } from "nestjs-zod"; +import { TableNodeSchema } from "./table.schema"; +import { DTONodeSchema } from "./dto.schema"; +import { ModelNodeSchema } from "./model.schema"; +import { EnumNodeSchema } from "./enum.schema"; +import { ViewNodeSchema } from "./view.schema"; +import { ServiceNodeSchema } from "./service.schema"; +import { WorkerNodeSchema } from "./worker.schema"; +import { EventHandlerNodeSchema } from "./event-handler.schema"; +import { ControllerNodeSchema } from "./controller.schema"; +import { MessageQueueNodeSchema } from "./message-queue.schema"; +import { RepositoryNodeSchema } from "./repository.schema"; +import { CacheNodeSchema } from "./cache.schema"; +import { ExternalServiceNodeSchema } from "./external-service.schema"; +import { FrontendAppNodeSchema } from "./frontend-app.schema"; +import { UIComponentNodeSchema } from "./ui-component.schema"; +import { MiddlewareNodeSchema } from "./middleware.schema"; +import { EnvironmentVariableNodeSchema } from "./env-variable.schema"; +import { ExceptionNodeSchema } from "./exception.schema"; +import { ModuleNodeSchema } from "./module.schema"; +import { APIGatewayNodeSchema } from "./api-gateway.schema"; +import { OrchestratorNodeSchema } from "./orchestrator.schema"; + +export { BaseNodeSchema, PositionSchema, type BaseNode, type Position } from "./base.schema"; +export { TableNodeSchema, type TableNode } from "./table.schema"; +export { DTONodeSchema, type DTONode } from "./dto.schema"; +export { ModelNodeSchema, type ModelNode } from "./model.schema"; +export { EnumNodeSchema, type EnumNode } from "./enum.schema"; +export { ViewNodeSchema, type ViewNode } from "./view.schema"; +export { ServiceNodeSchema, type ServiceNode } from "./service.schema"; +export { WorkerNodeSchema, type WorkerNode } from "./worker.schema"; +export { EventHandlerNodeSchema, type EventHandlerNode } from "./event-handler.schema"; +export { ControllerNodeSchema, type ControllerNode } from "./controller.schema"; +export { MessageQueueNodeSchema, type MessageQueueNode } from "./message-queue.schema"; +export { RepositoryNodeSchema, type RepositoryNode } from "./repository.schema"; +export { CacheNodeSchema, type CacheNode } from "./cache.schema"; +export { ExternalServiceNodeSchema, type ExternalServiceNode } from "./external-service.schema"; +export { FrontendAppNodeSchema, type FrontendAppNode } from "./frontend-app.schema"; +export { UIComponentNodeSchema, type UIComponentNode } from "./ui-component.schema"; +export { MiddlewareNodeSchema, type MiddlewareNode } from "./middleware.schema"; +export { EnvironmentVariableNodeSchema, type EnvironmentVariableNode } from "./env-variable.schema"; +export { ExceptionNodeSchema, type ExceptionNode } from "./exception.schema"; +export { ModuleNodeSchema, type ModuleNode } from "./module.schema"; +export { APIGatewayNodeSchema, type APIGatewayNode } from "./api-gateway.schema"; +export { OrchestratorNodeSchema, type OrchestratorNode } from "./orchestrator.schema"; + +export const NodeSchema = z.discriminatedUnion("type", [ + // Data family (Phase 1) + TableNodeSchema, + DTONodeSchema, + ModelNodeSchema, + EnumNodeSchema, + ViewNodeSchema, + // Business Logic + ServiceNodeSchema, + WorkerNodeSchema, + EventHandlerNodeSchema, + // Access + ControllerNodeSchema, + MessageQueueNodeSchema, + // Infrastructure + RepositoryNodeSchema, + CacheNodeSchema, + ExternalServiceNodeSchema, + // Client + FrontendAppNodeSchema, + UIComponentNodeSchema, + // Security + MiddlewareNodeSchema, + // Configuration + EnvironmentVariableNodeSchema, + ExceptionNodeSchema, + // Structure + ModuleNodeSchema, + // Phase 2A — additional types required by Rules Matrix + APIGatewayNodeSchema, + OrchestratorNodeSchema, +]); + +export type Node = z.infer; +export type NodeKind = Node["type"]; + +export const NODE_KINDS: NodeKind[] = [ + "Table", "DTO", "Model", "Enum", "View", + "Service", "Worker", "EventHandler", + "Controller", "MessageQueue", + "Repository", "Cache", "ExternalService", + "FrontendApp", "UIComponent", + "Middleware", + "EnvironmentVariable", "Exception", + "Module", + "APIGateway", "Orchestrator", +]; + +/** Kind → properties Zod schema. Single source for kind-based validation on + * write paths (PATCH, AI create_node). Uses the same `.shape.properties` as + * createZodDto/CreateNodeSchema → consistency. */ +export const PROPERTIES_SCHEMA_BY_KIND: Record = { + Table: TableNodeSchema.shape.properties, + DTO: DTONodeSchema.shape.properties, + Model: ModelNodeSchema.shape.properties, + Enum: EnumNodeSchema.shape.properties, + View: ViewNodeSchema.shape.properties, + Service: ServiceNodeSchema.shape.properties, + Worker: WorkerNodeSchema.shape.properties, + EventHandler: EventHandlerNodeSchema.shape.properties, + Controller: ControllerNodeSchema.shape.properties, + MessageQueue: MessageQueueNodeSchema.shape.properties, + Repository: RepositoryNodeSchema.shape.properties, + Cache: CacheNodeSchema.shape.properties, + ExternalService: ExternalServiceNodeSchema.shape.properties, + FrontendApp: FrontendAppNodeSchema.shape.properties, + UIComponent: UIComponentNodeSchema.shape.properties, + Middleware: MiddlewareNodeSchema.shape.properties, + EnvironmentVariable: EnvironmentVariableNodeSchema.shape.properties, + Exception: ExceptionNodeSchema.shape.properties, + Module: ModuleNodeSchema.shape.properties, + APIGateway: APIGatewayNodeSchema.shape.properties, + Orchestrator: OrchestratorNodeSchema.shape.properties, +}; + +/* ── Per-kind DTO classes ───────────────────────────────────────────── + * createZodDto gives NestJS Swagger a familiar class per schema. + * All enter OpenAPI components/schemas via main.ts extraModels and appear + * individually in Scalar UI's Models panel. */ +export class TableNodeDto extends createZodDto(TableNodeSchema) {} +export class DTONodeDto extends createZodDto(DTONodeSchema) {} +export class ModelNodeDto extends createZodDto(ModelNodeSchema) {} +export class EnumNodeDto extends createZodDto(EnumNodeSchema) {} +export class ViewNodeDto extends createZodDto(ViewNodeSchema) {} +export class ServiceNodeDto extends createZodDto(ServiceNodeSchema) {} +export class WorkerNodeDto extends createZodDto(WorkerNodeSchema) {} +export class EventHandlerNodeDto extends createZodDto(EventHandlerNodeSchema) {} +export class ControllerNodeDto extends createZodDto(ControllerNodeSchema) {} +export class MessageQueueNodeDto extends createZodDto(MessageQueueNodeSchema) {} +export class RepositoryNodeDto extends createZodDto(RepositoryNodeSchema) {} +export class CacheNodeDto extends createZodDto(CacheNodeSchema) {} +export class ExternalServiceNodeDto extends createZodDto(ExternalServiceNodeSchema) {} +export class FrontendAppNodeDto extends createZodDto(FrontendAppNodeSchema) {} +export class UIComponentNodeDto extends createZodDto(UIComponentNodeSchema) {} +export class MiddlewareNodeDto extends createZodDto(MiddlewareNodeSchema) {} +export class EnvironmentVariableNodeDto extends createZodDto(EnvironmentVariableNodeSchema) {} +export class ExceptionNodeDto extends createZodDto(ExceptionNodeSchema) {} +export class ModuleNodeDto extends createZodDto(ModuleNodeSchema) {} +export class APIGatewayNodeDto extends createZodDto(APIGatewayNodeSchema) {} +export class OrchestratorNodeDto extends createZodDto(OrchestratorNodeSchema) {} + +export const ALL_NODE_DTOS = [ + TableNodeDto, DTONodeDto, ModelNodeDto, EnumNodeDto, ViewNodeDto, + ServiceNodeDto, WorkerNodeDto, EventHandlerNodeDto, + ControllerNodeDto, MessageQueueNodeDto, + RepositoryNodeDto, CacheNodeDto, ExternalServiceNodeDto, + FrontendAppNodeDto, UIComponentNodeDto, + MiddlewareNodeDto, + EnvironmentVariableNodeDto, ExceptionNodeDto, + ModuleNodeDto, + APIGatewayNodeDto, + OrchestratorNodeDto, +]; + +export const KIND_LABELS: Record = { + Table: "Table", + DTO: "DTO", + Model: "Model", + Enum: "Enum", + View: "View", + Service: "Service", + Worker: "Worker", + EventHandler: "EventHandler", + Controller: "Controller", + MessageQueue: "MessageQueue", + Repository: "Repository", + Cache: "Cache", + ExternalService: "ExternalService", + FrontendApp: "FrontendApp", + UIComponent: "UIComponent", + Middleware: "Middleware", + EnvironmentVariable: "EnvironmentVariable", + Exception: "Exception", + Module: "Module", + APIGateway: "APIGateway", + Orchestrator: "Orchestrator", +}; diff --git a/apps/server/src/nodes/schemas/message-queue.schema.spec.ts b/apps/server/src/nodes/schemas/message-queue.schema.spec.ts new file mode 100644 index 0000000..8dedf0d --- /dev/null +++ b/apps/server/src/nodes/schemas/message-queue.schema.spec.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { MessageQueueNodeSchema } from "./message-queue.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + QueueName: "order-events", + Description: "Order events", + Type: "Topic" as const, + Provider: "Kafka" as const, + MessageFormat: "OrderEventDTO", +}; + +const parse = (properties: unknown) => + MessageQueueNodeSchema.parse({ ...validBase, type: "MessageQueue", properties }); + +describe("MessageQueueNodeSchema (enriched)", () => { + it("parses valid MessageQueue'yu", () => { + const node = parse(validProperties); + expect(node.properties.Provider).toBe("Kafka"); + }); + + it("teslim garantisi + DLQ + retention kabul eder", () => { + const node = parse({ + ...validProperties, + DeliveryGuarantee: "exactly-once", + MaxRetries: 3, + DeadLetterQueue: "order-events-dlq", + RetentionSeconds: 604800, + }); + expect(node.properties.DeliveryGuarantee).toBe("exactly-once"); + expect(node.properties.RetentionSeconds).toBe(604800); + }); + + it("rejects invalid DeliveryGuarantee", () => { + expect(() => parse({ ...validProperties, DeliveryGuarantee: "best-effort" })).toThrow(); + }); + + it("Bilinmeyen Provider reddeder", () => { + expect(() => parse({ ...validProperties, Provider: "Redis" })).toThrow(); + }); + + it("Description zorunlu", () => { + const { Description, ...rest } = validProperties; + expect(() => parse(rest)).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/message-queue.schema.ts b/apps/server/src/nodes/schemas/message-queue.schema.ts new file mode 100644 index 0000000..b9b92cd --- /dev/null +++ b/apps/server/src/nodes/schemas/message-queue.schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +export const MessageQueueNodeSchema = BaseNodeSchema.extend({ + type: z.literal("MessageQueue"), + properties: z.object({ + QueueName: z.string().min(1), + Description: z.string().min(1), + Type: z.enum(["Queue", "Topic"]), + Provider: z.enum(["RabbitMQ", "Kafka", "AWS_SQS", "Generic"]), + MessageFormat: z.string().min(1).describe("Message body → DTO node Name"), + DeliveryGuarantee: z.enum(["at-least-once", "exactly-once", "at-most-once"]).optional(), + MaxRetries: z.number().int().nonnegative().optional(), + DeadLetterQueue: z.string().optional(), + RetentionSeconds: z.number().int().positive().optional(), + }).strict(), +}).strict(); + +export type MessageQueueNode = z.infer; diff --git a/apps/server/src/nodes/schemas/middleware.schema.spec.ts b/apps/server/src/nodes/schemas/middleware.schema.spec.ts new file mode 100644 index 0000000..bd3a11e --- /dev/null +++ b/apps/server/src/nodes/schemas/middleware.schema.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { MiddlewareNodeSchema } from "./middleware.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + MiddlewareName: "RateLimiterMiddleware", + Description: "Request rate limiting", + AppliesTo: "Global" as const, + ExecutionOrder: 1, +}; + +const parse = (properties: unknown) => + MiddlewareNodeSchema.parse({ ...validBase, type: "Middleware", properties }); + +describe("MiddlewareNodeSchema (enriched)", () => { + it("parses valid Middleware (Config default empty)", () => { + const node = parse(validProperties); + expect(node.properties.ExecutionOrder).toBe(1); + expect(node.properties.Config).toEqual([]); + }); + + it("accepts MiddlewareType + Config", () => { + const node = parse({ + ...validProperties, + MiddlewareType: "RateLimit", + Config: [{ Key: "limit", Value: "100" }, { Key: "window", Value: "60s" }], + }); + expect(node.properties.MiddlewareType).toBe("RateLimit"); + expect(node.properties.Config).toHaveLength(2); + }); + + it("rejects invalid MiddlewareType", () => { + expect(() => parse({ ...validProperties, MiddlewareType: "Caching" })).toThrow(); + }); + + it("rejects unknown AppliesTo", () => { + expect(() => parse({ ...validProperties, AppliesTo: "Module" })).toThrow(); + }); + + it("ExecutionOrder cannot be negative", () => { + expect(() => parse({ ...validProperties, ExecutionOrder: -1 })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/middleware.schema.ts b/apps/server/src/nodes/schemas/middleware.schema.ts new file mode 100644 index 0000000..9498bdd --- /dev/null +++ b/apps/server/src/nodes/schemas/middleware.schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +export const MiddlewareNodeSchema = BaseNodeSchema.extend({ + type: z.literal("Middleware"), + properties: z.object({ + MiddlewareName: z.string().min(1), + Description: z.string().min(1), + AppliesTo: z.enum(["Global", "SpecificRoutes"]), + ExecutionOrder: z.number().int().nonnegative(), + MiddlewareType: z.enum(["Auth", "Logging", "RateLimit", "Cors", "Compression", "ErrorHandler", "Custom"]).optional(), + Config: z.array(z.object({ Key: z.string().min(1), Value: z.string() })).default([]).describe("middleware configuration key-values"), + }).strict(), +}).strict(); + +export type MiddlewareNode = z.infer; diff --git a/apps/server/src/nodes/schemas/model.schema.spec.ts b/apps/server/src/nodes/schemas/model.schema.spec.ts new file mode 100644 index 0000000..cbd5831 --- /dev/null +++ b/apps/server/src/nodes/schemas/model.schema.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; +import { ModelNodeSchema } from "./model.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + ClassName: "User", + Description: "User entity class", + Properties: [ + { Name: "id", Type: "UUID" }, + { Name: "email", Type: "string" }, + ], +}; + +const parse = (properties: unknown) => + ModelNodeSchema.parse({ ...validBase, type: "Model", properties }); + +describe("ModelNodeSchema (enriched)", () => { + it("parses valid Model", () => { + const node = parse(validProperties); + expect(node.properties.ClassName).toBe("User"); + }); + + it("Property nullable/collection default false", () => { + const node = parse(validProperties); + expect(node.properties.Properties[0].IsNullable).toBe(false); + expect(node.properties.Properties[0].IsCollection).toBe(false); + }); + + it("accepts related property (OneToMany + RelatedModelRef)", () => { + const node = parse({ + ...validProperties, + Properties: [ + { Name: "id", Type: "UUID" }, + { Name: "orders", Type: "Order", IsCollection: true, RelationType: "OneToMany", RelatedModelRef: "Order" }, + ], + }); + expect(node.properties.Properties[1].RelationType).toBe("OneToMany"); + expect(node.properties.Properties[1].RelatedModelRef).toBe("Order"); + }); + + it("rejects invalid RelationType", () => { + expect(() => parse({ + ...validProperties, + Properties: [{ Name: "x", Type: "Y", RelationType: "ManyToNone" }], + })).toThrow(); + }); + + it("accepts TableRef", () => { + const node = parse({ ...validProperties, TableRef: "users" }); + expect(node.properties.TableRef).toBe("users"); + }); + + it("accepts typed method signature (parameters + async)", () => { + const node = parse({ + ...validProperties, + Methods: [{ + MethodName: "rename", + Parameters: [{ Name: "name", Type: "string" }, { Name: "force", Type: "boolean", Optional: true }], + ReturnType: "void", + IsAsync: true, + }], + }); + const m = node.properties.Methods[0]; + expect(m.Visibility).toBe("public"); + expect(m.Parameters).toHaveLength(2); + expect(m.Parameters[1].Optional).toBe(true); + expect(m.IsAsync).toBe(true); + }); + + it("Description zorunlu", () => { + const { Description, ...rest } = validProperties; + expect(() => parse(rest)).toThrow(); + }); + + it("throws when Properties is empty", () => { + expect(() => parse({ ...validProperties, Properties: [] })).toThrow(); + }); + + it("defaults Methods to empty array", () => { + const node = parse(validProperties); + expect(node.properties.Methods).toEqual([]); + }); +}); diff --git a/apps/server/src/nodes/schemas/model.schema.ts b/apps/server/src/nodes/schemas/model.schema.ts new file mode 100644 index 0000000..89dbcb6 --- /dev/null +++ b/apps/server/src/nodes/schemas/model.schema.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +const RELATION = ["OneToOne", "OneToMany", "ManyToOne", "ManyToMany"] as const; + +const PropertySchema = z.object({ + Name: z.string().min(1), + Type: z.string().min(1), + IsNullable: z.boolean().default(false), + IsCollection: z.boolean().default(false), + RelationType: z.enum(RELATION).optional(), + RelatedModelRef: z.string().optional().describe("→ Model node ClassName"), +}).strict(); + +const MethodSchema = z.object({ + MethodName: z.string().min(1), + Visibility: z.enum(["public", "private", "protected"]).default("public"), + Parameters: z.array(z.object({ + Name: z.string().min(1), + Type: z.string().min(1), + Optional: z.boolean().default(false), + Default: z.string().optional(), + })).default([]), + ReturnType: z.string().min(1), + IsAsync: z.boolean().default(false), + IsStatic: z.boolean().default(false), +}).strict(); + +export const ModelNodeSchema = BaseNodeSchema.extend({ + type: z.literal("Model"), + properties: z.object({ + ClassName: z.string().min(1), + Description: z.string().min(1), + TableRef: z.string().optional().describe("→ Table node TableName"), + Properties: z.array(PropertySchema).min(1), + Methods: z.array(MethodSchema).default([]), + }).strict(), +}).strict(); + +export type ModelNode = z.infer; diff --git a/apps/server/src/nodes/schemas/module.schema.spec.ts b/apps/server/src/nodes/schemas/module.schema.spec.ts new file mode 100644 index 0000000..27858b9 --- /dev/null +++ b/apps/server/src/nodes/schemas/module.schema.spec.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { ModuleNodeSchema } from "./module.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + ModuleName: "BillingContext", + Description: "Billing context", + StrictBoundaries: true, +}; + +const parse = (properties: unknown) => + ModuleNodeSchema.parse({ ...validBase, type: "Module", properties }); + +describe("ModuleNodeSchema (enriched)", () => { + it("parses valid Module (ExposedServices/Dependencies default empty)", () => { + const node = parse(validProperties); + expect(node.properties.StrictBoundaries).toBe(true); + expect(node.properties.ExposedServices).toEqual([]); + expect(node.properties.Dependencies).toEqual([]); + }); + + it("accepts ExposedServices + Dependencies", () => { + const node = parse({ + ...validProperties, + ExposedServices: ["InvoiceService", "PaymentService"], + Dependencies: ["UserContext"], + }); + expect(node.properties.ExposedServices).toEqual(["InvoiceService", "PaymentService"]); + expect(node.properties.Dependencies).toEqual(["UserContext"]); + }); + + it("ModuleName is required", () => { + const { ModuleName, ...rest } = validProperties; + expect(() => parse(rest)).toThrow(); + }); + + it("throws when StrictBoundaries is not boolean", () => { + expect(() => parse({ ...validProperties, StrictBoundaries: "yes" })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/module.schema.ts b/apps/server/src/nodes/schemas/module.schema.ts new file mode 100644 index 0000000..e90723d --- /dev/null +++ b/apps/server/src/nodes/schemas/module.schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +export const ModuleNodeSchema = BaseNodeSchema.extend({ + type: z.literal("Module"), + properties: z.object({ + ModuleName: z.string().min(1), + Description: z.string().min(1), + StrictBoundaries: z.boolean(), + ExposedServices: z.array(z.string().min(1)).default([]).describe("exposed → Service node Names (public API)"), + Dependencies: z.array(z.string().min(1)).default([]).describe("depended-on → Module node Names"), + }).strict(), +}).strict(); + +export type ModuleNode = z.infer; diff --git a/apps/server/src/nodes/schemas/orchestrator.schema.spec.ts b/apps/server/src/nodes/schemas/orchestrator.schema.spec.ts new file mode 100644 index 0000000..38cc665 --- /dev/null +++ b/apps/server/src/nodes/schemas/orchestrator.schema.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { OrchestratorNodeSchema } from "./orchestrator.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-22T10:30:00.000Z", + updatedAt: "2026-05-22T10:30:00.000Z", +}; + +const validProperties = { + OrchestratorName: "OrderSaga", + Description: "Order saga coordination", + Pattern: "Saga" as const, +}; + +const parse = (properties: unknown) => + OrchestratorNodeSchema.parse({ ...validBase, type: "Orchestrator", properties }); + +describe("OrchestratorNodeSchema (enriched)", () => { + it("parses valid Orchestrator (Steps default empty)", () => { + const node = parse(validProperties); + expect(node.properties.Pattern).toBe("Saga"); + expect(node.properties.Steps).toEqual([]); + }); + + it("accepts Steps (ServiceRef + CompensationAction + OnFailure)", () => { + const node = parse({ + ...validProperties, + Steps: [ + { StepName: "reserveStock", ServiceRef: "InventoryService", Action: "reserve", CompensationAction: "release", OnFailure: "compensate" }, + { StepName: "charge", ServiceRef: "PaymentService", Action: "charge" }, + ], + }); + expect(node.properties.Steps).toHaveLength(2); + expect(node.properties.Steps[0].OnFailure).toBe("compensate"); + expect(node.properties.Steps[1].OnFailure).toBe("abort"); // default + }); + + it("rejects invalid OnFailure", () => { + expect(() => parse({ + ...validProperties, + Steps: [{ StepName: "x", ServiceRef: "S", Action: "a", OnFailure: "ignore" }], + })).toThrow(); + }); + + it("Description is required", () => { + const { Description, ...rest } = validProperties; + expect(() => parse(rest)).toThrow(); + }); + + it("rejects unknown Pattern", () => { + expect(() => parse({ ...validProperties, Pattern: "Choreography" })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/orchestrator.schema.ts b/apps/server/src/nodes/schemas/orchestrator.schema.ts new file mode 100644 index 0000000..2941625 --- /dev/null +++ b/apps/server/src/nodes/schemas/orchestrator.schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +const StepSchema = z.object({ + StepName: z.string().min(1), + ServiceRef: z.string().min(1).describe("Step-executing → Service node Name"), + Action: z.string().min(1), + CompensationAction: z.string().optional().describe("Saga geri-alma aksiyonu"), + OnFailure: z.enum(["retry", "compensate", "abort"]).default("abort"), +}).strict(); + +export const OrchestratorNodeSchema = BaseNodeSchema.extend({ + type: z.literal("Orchestrator"), + properties: z.object({ + OrchestratorName: z.string().min(1), + Description: z.string().min(1), + Pattern: z.enum(["Saga", "CompensatingTransaction", "StateMachine", "ProcessManager"]), + Steps: z.array(StepSchema).default([]), + }).strict(), +}).strict(); + +export type OrchestratorNode = z.infer; diff --git a/apps/server/src/nodes/schemas/repository.schema.spec.ts b/apps/server/src/nodes/schemas/repository.schema.spec.ts new file mode 100644 index 0000000..b5493c5 --- /dev/null +++ b/apps/server/src/nodes/schemas/repository.schema.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { RepositoryNodeSchema } from "./repository.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + RepositoryName: "UserRepository", + Description: "User data access layer", + EntityReference: "User", + CustomQueries: [ + { QueryName: "findByEmail", QueryType: "findOne" as const, Parameters: [{ Name: "email", Type: "string" }], ReturnType: "User" }, + ], +}; + +const parse = (properties: unknown) => + RepositoryNodeSchema.parse({ ...validBase, type: "Repository", properties }); + +describe("RepositoryNodeSchema (enriched)", () => { + it("parses valid Repository", () => { + const node = parse(validProperties); + expect(node.properties.CustomQueries[0].QueryName).toBe("findByEmail"); + }); + + it("IsCached default false, QueryType/Parameters default", () => { + const node = parse({ ...validProperties, IsCached: undefined, CustomQueries: [{ QueryName: "all", ReturnType: "User[]" }] }); + expect(node.properties.IsCached).toBe(false); + expect(node.properties.CustomQueries[0].QueryType).toBe("custom"); + expect(node.properties.CustomQueries[0].Parameters).toEqual([]); + }); + + it("accepts BaseClass + IsCached", () => { + const node = parse({ ...validProperties, BaseClass: "TypeOrmRepository", IsCached: true }); + expect(node.properties.BaseClass).toBe("TypeOrmRepository"); + expect(node.properties.IsCached).toBe(true); + }); + + it("rejects legacy string[] CustomQueries format", () => { + expect(() => parse({ ...validProperties, CustomQueries: ["findByEmail"] })).toThrow(); + }); + + it("EntityReference is required", () => { + const { EntityReference, ...rest } = validProperties; + expect(() => parse(rest)).toThrow(); + }); + + it("defaults CustomQueries to empty array", () => { + const { CustomQueries, ...partial } = validProperties; + expect(parse(partial).properties.CustomQueries).toEqual([]); + }); +}); diff --git a/apps/server/src/nodes/schemas/repository.schema.ts b/apps/server/src/nodes/schemas/repository.schema.ts new file mode 100644 index 0000000..6974ee8 --- /dev/null +++ b/apps/server/src/nodes/schemas/repository.schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +const CustomQuerySchema = z.object({ + QueryName: z.string().min(1), + QueryType: z.enum(["find", "findOne", "aggregate", "raw", "custom"]).default("custom"), + Parameters: z.array(z.object({ Name: z.string().min(1), Type: z.string().min(1) })).default([]), + ReturnType: z.string().min(1), + Description: z.string().optional(), +}).strict(); + +export const RepositoryNodeSchema = BaseNodeSchema.extend({ + type: z.literal("Repository"), + properties: z.object({ + RepositoryName: z.string().min(1), + Description: z.string().min(1), + EntityReference: z.string().min(1).describe("Managed → Model/Table node Name"), + BaseClass: z.string().optional().describe("Inherited repository base class"), + IsCached: z.boolean().default(false), + CustomQueries: z.array(CustomQuerySchema).default([]), + }).strict(), +}).strict(); + +export type RepositoryNode = z.infer; diff --git a/apps/server/src/nodes/schemas/service.schema.spec.ts b/apps/server/src/nodes/schemas/service.schema.spec.ts new file mode 100644 index 0000000..321c473 --- /dev/null +++ b/apps/server/src/nodes/schemas/service.schema.spec.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { ServiceNodeSchema } from "./service.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + ServiceName: "PaymentService", + Description: "Payment processing", + IsTransactionScoped: true, + Methods: [ + { + MethodName: "processPayment", + Parameters: [{ Name: "req", Type: "PaymentRequestDTO", DtoRef: "PaymentRequestDTO" }], + ReturnType: "PaymentResponseDTO", + ReturnDtoRef: "PaymentResponseDTO", + IsAsync: true, + Throws: ["PaymentFailedException"], + }, + ], +}; + +const parse = (properties: unknown) => + ServiceNodeSchema.parse({ ...validBase, type: "Service", properties }); + +describe("ServiceNodeSchema (enriched)", () => { + it("parses valid Service", () => { + const node = parse(validProperties); + expect(node.properties.IsTransactionScoped).toBe(true); + expect(node.properties.Methods[0].Parameters).toHaveLength(1); + }); + + it("method defaults (Visibility/Parameters/IsAsync/Throws)", () => { + const node = parse({ ...validProperties, Methods: [{ MethodName: "ping", ReturnType: "void" }] }); + const m = node.properties.Methods[0]; + expect(m.Visibility).toBe("public"); + expect(m.Parameters).toEqual([]); + expect(m.IsAsync).toBe(false); + expect(m.Throws).toEqual([]); + }); + + it("Dependencies (DI) default empty, accepts valid Kind", () => { + expect(parse(validProperties).properties.Dependencies).toEqual([]); + const node = parse({ ...validProperties, Dependencies: [{ Kind: "Repository", Ref: "UserRepository" }] }); + expect(node.properties.Dependencies[0].Kind).toBe("Repository"); + }); + + it("rejects invalid Dependency Kind", () => { + expect(() => parse({ ...validProperties, Dependencies: [{ Kind: "Database", Ref: "x" }] })).toThrow(); + }); + + it("rejects legacy InputParams field (strict)", () => { + expect(() => parse({ ...validProperties, Methods: [{ MethodName: "x", InputParams: [], ReturnType: "void" }] })).toThrow(); + }); + + it("Description is required", () => { + const { Description, ...rest } = validProperties; + expect(() => parse(rest)).toThrow(); + }); + + it("throws when Methods is empty", () => { + expect(() => parse({ ...validProperties, Methods: [] })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/service.schema.ts b/apps/server/src/nodes/schemas/service.schema.ts new file mode 100644 index 0000000..8ab8a94 --- /dev/null +++ b/apps/server/src/nodes/schemas/service.schema.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +const MethodParamSchema = z.object({ + Name: z.string().min(1), + Type: z.string().min(1), + Optional: z.boolean().default(false), + Default: z.string().optional(), + DtoRef: z.string().optional().describe("If the parameter type is a DTO → DTO node Name"), +}).strict(); + +const ServiceMethodSchema = z.object({ + MethodName: z.string().min(1), + Visibility: z.enum(["public", "private", "protected"]).default("public"), + Parameters: z.array(MethodParamSchema).default([]), + ReturnType: z.string().min(1), + ReturnDtoRef: z.string().optional().describe("If the return type is a DTO → DTO node Name"), + ReturnsCollection: z + .boolean() + .optional() + .describe( + "SINGLE SOURCE of cardinality: does the operation return a collection? When declared, " + + "the emitter forces the return type to DTO[] (aligned with the controller's route inference). " + + "When omitted, falls back to the [] in ReturnType / method-name list semantics.", + ), + IsAsync: z.boolean().default(false), + Throws: z.array(z.string().min(1)).default([]).describe("throwable → Exception node Names"), + Description: z.string().optional(), +}).strict(); + +const DependencySchema = z.object({ + Kind: z.enum(["Repository", "Service", "Cache", "ExternalService"]), + Ref: z.string().min(1).describe("Name of the dependency node (DI)"), +}).strict(); + +export const ServiceNodeSchema = BaseNodeSchema.extend({ + type: z.literal("Service"), + properties: z.object({ + ServiceName: z.string().min(1), + Description: z.string().min(1), + IsTransactionScoped: z.boolean(), + Methods: z.array(ServiceMethodSchema).min(1), + Dependencies: z.array(DependencySchema).default([]), + }).strict(), +}).strict(); + +export type ServiceNode = z.infer; diff --git a/apps/server/src/nodes/schemas/table.schema.spec.ts b/apps/server/src/nodes/schemas/table.schema.spec.ts new file mode 100644 index 0000000..29f292d --- /dev/null +++ b/apps/server/src/nodes/schemas/table.schema.spec.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; +import { TableNodeSchema } from "./table.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const col = (over: Record = {}) => ({ + Name: "id", + DataType: "UUID", + IsPrimaryKey: true, + IsNotNull: true, + IsUnique: true, + AutoIncrement: false, + ...over, +}); + +const validProperties = { + TableName: "users", + Description: "Registered users", + Columns: [col()], +}; + +const parse = (properties: unknown) => + TableNodeSchema.parse({ ...validBase, type: "Table", properties }); + +describe("TableNodeSchema (enriched)", () => { + it("parses valid Table node", () => { + const node = parse(validProperties); + expect(node.properties.TableName).toBe("users"); + }); + + it("defaults constraint arrays to empty when omitted", () => { + const node = parse(validProperties); + expect(node.properties.ForeignKeys).toEqual([]); + expect(node.properties.UniqueConstraints).toEqual([]); + expect(node.properties.CheckConstraints).toEqual([]); + expect(node.properties.Indexes).toEqual([]); + }); + + it("composite PrimaryKey kabul eder", () => { + const node = parse({ + ...validProperties, + Columns: [col({ Name: "order_id", IsPrimaryKey: false }), col({ Name: "product_id", IsPrimaryKey: false })], + PrimaryKey: { Columns: ["order_id", "product_id"] }, + }); + expect(node.properties.PrimaryKey?.Columns).toEqual(["order_id", "product_id"]); + }); + + it("ForeignKey FK action default NO_ACTION", () => { + const node = parse({ + ...validProperties, + ForeignKeys: [{ Columns: ["org_id"], ReferencesTable: "orgs", ReferencesColumns: ["id"] }], + }); + expect(node.properties.ForeignKeys[0].OnDelete).toBe("NO_ACTION"); + expect(node.properties.ForeignKeys[0].OnUpdate).toBe("NO_ACTION"); + }); + + it("ForeignKey OnDelete=CASCADE kabul eder", () => { + const node = parse({ + ...validProperties, + ForeignKeys: [{ Columns: ["org_id"], ReferencesTable: "orgs", ReferencesColumns: ["id"], OnDelete: "CASCADE" }], + }); + expect(node.properties.ForeignKeys[0].OnDelete).toBe("CASCADE"); + }); + + it("rejects invalid FK action", () => { + expect(() => parse({ + ...validProperties, + ForeignKeys: [{ Columns: ["x"], ReferencesTable: "t", ReferencesColumns: ["id"], OnDelete: "DROP" }], + })).toThrow(); + }); + + it("accepts DECIMAL precision/scale and ENUM EnumRef columns", () => { + const node = parse({ + ...validProperties, + Columns: [ + col(), + col({ Name: "price", DataType: "DECIMAL", Precision: 10, Scale: 2, IsPrimaryKey: false, IsUnique: false }), + col({ Name: "status", DataType: "ENUM", EnumRef: "OrderStatus", IsPrimaryKey: false, IsUnique: false }), + ], + }); + expect(node.properties.Columns[1].Precision).toBe(10); + expect(node.properties.Columns[2].EnumRef).toBe("OrderStatus"); + }); + + it("accepts rich Index (unique + partial + WhereClause)", () => { + const node = parse({ + ...validProperties, + Indexes: [{ IndexName: "idx_active", Columns: ["id"], Type: "GIN", IsUnique: true, IsPartial: true, WhereClause: "active = true" }], + }); + expect(node.properties.Indexes[0].Type).toBe("GIN"); + expect(node.properties.Indexes[0].IsPartial).toBe(true); + }); + + it("Index Type/IsUnique default", () => { + const node = parse({ + ...validProperties, + Indexes: [{ IndexName: "idx_id", Columns: ["id"] }], + }); + expect(node.properties.Indexes[0].Type).toBe("BTree"); + expect(node.properties.Indexes[0].IsUnique).toBe(false); + }); + + it("CheckConstraint kabul eder", () => { + const node = parse({ + ...validProperties, + CheckConstraints: [{ Name: "age_chk", Expression: "age >= 0" }], + }); + expect(node.properties.CheckConstraints[0].Expression).toBe("age >= 0"); + }); + + it("throws when Description is missing", () => { + const { Description, ...rest } = validProperties; + expect(() => parse(rest)).toThrow(); + }); + + it("throws when Columns is empty", () => { + expect(() => parse({ ...validProperties, Columns: [] })).toThrow(); + }); + + it("rejects unknown DataType", () => { + expect(() => parse({ ...validProperties, Columns: [col({ DataType: "FOOBAR" })] })).toThrow(); + }); + + it("rejects legacy IsForeignKey/References field (strict)", () => { + expect(() => parse({ ...validProperties, Columns: [col({ IsForeignKey: false })] })).toThrow(); + }); + + it("rejects unknown field in properties (strict)", () => { + expect(() => parse({ ...validProperties, ExtraField: "x" })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/table.schema.ts b/apps/server/src/nodes/schemas/table.schema.ts new file mode 100644 index 0000000..51c9828 --- /dev/null +++ b/apps/server/src/nodes/schemas/table.schema.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +const DATA_TYPES = ["INT", "BIGINT", "VARCHAR", "TEXT", "BOOLEAN", "DATETIME", "DATE", "UUID", "FLOAT", "DECIMAL", "JSON", "ENUM"] as const; +const FK_ACTION = ["CASCADE", "RESTRICT", "SET_NULL", "NO_ACTION"] as const; + +const ColumnSchema = z.object({ + Name: z.string().min(1).describe("Column name"), + DataType: z.enum(DATA_TYPES).describe("SQL veri tipi"), + Length: z.number().int().positive().optional().describe("VARCHAR(n) length"), + Precision: z.number().int().positive().optional().describe("DECIMAL(p,s) precision"), + Scale: z.number().int().nonnegative().optional().describe("DECIMAL(p,s) scale"), + IsPrimaryKey: z.boolean().describe("Tek-kolon PK"), + IsNotNull: z.boolean().describe("NOT NULL"), + IsUnique: z.boolean().describe("UNIQUE"), + AutoIncrement: z.boolean().describe("AUTO_INCREMENT / SERIAL"), + DefaultValue: z.string().optional().describe("Default value expression"), + Comment: z.string().optional().describe("Kolon yorumu"), + EnumRef: z.string().optional().describe("DataType=ENUM ise → Enum node Name"), + IsGenerated: z.boolean().optional().describe("GENERATED kolon"), + GeneratedExpression: z.string().optional().describe("Generated kolon ifadesi"), +}).strict(); + +const ForeignKeySchema = z.object({ + Name: z.string().optional(), + Columns: z.array(z.string().min(1)).min(1), + ReferencesTable: z.string().min(1).describe("Hedef Table Name"), + ReferencesColumns: z.array(z.string().min(1)).min(1), + OnDelete: z.enum(FK_ACTION).default("NO_ACTION"), + OnUpdate: z.enum(FK_ACTION).default("NO_ACTION"), +}).strict(); + +const IndexSchema = z.object({ + IndexName: z.string().min(1), + Columns: z.array(z.string().min(1)).min(1), + Type: z.enum(["BTree", "Hash", "GIN", "GiST"]).default("BTree"), + IsUnique: z.boolean().default(false), + IsPartial: z.boolean().optional(), + WhereClause: z.string().optional(), +}).strict(); + +export const TableNodeSchema = BaseNodeSchema.extend({ + type: z.literal("Table"), + properties: z.object({ + TableName: z.string().min(1), + Description: z.string().min(1), + Columns: z.array(ColumnSchema).min(1), + PrimaryKey: z.object({ Columns: z.array(z.string().min(1)).min(1) }).optional().describe("Composite PK (use Column.IsPrimaryKey for single-column)"), + ForeignKeys: z.array(ForeignKeySchema).default([]), + UniqueConstraints: z.array(z.object({ Name: z.string().optional(), Columns: z.array(z.string().min(1)).min(1) })).default([]), + CheckConstraints: z.array(z.object({ Name: z.string().optional(), Expression: z.string().min(1) })).default([]), + Indexes: z.array(IndexSchema).default([]), + }).strict(), +}).strict(); + +export type TableNode = z.infer; diff --git a/apps/server/src/nodes/schemas/ui-component.schema.spec.ts b/apps/server/src/nodes/schemas/ui-component.schema.spec.ts new file mode 100644 index 0000000..f9d3104 --- /dev/null +++ b/apps/server/src/nodes/schemas/ui-component.schema.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { UIComponentNodeSchema } from "./ui-component.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + ComponentName: "UserDataTable", + Description: "User table", + Props: [{ Name: "users", Type: "User[]", Required: true }], + State: [{ Name: "selectedId", Type: "string | null" }], +}; + +const parse = (properties: unknown) => + UIComponentNodeSchema.parse({ ...validBase, type: "UIComponent", properties }); + +describe("UIComponentNodeSchema (enriched)", () => { + it("parses valid UIComponent", () => { + const node = parse(validProperties); + expect(node.properties.Props[0].Name).toBe("users"); + expect(node.properties.Props[0].Required).toBe(true); + }); + + it("Prop.Required default false", () => { + const node = parse({ ...validProperties, Props: [{ Name: "title", Type: "string" }] }); + expect(node.properties.Props[0].Required).toBe(false); + }); + + it("accepts Events + ChildComponentRefs", () => { + const node = parse({ + ...validProperties, + Events: [{ Name: "onRowClick", PayloadType: "User" }], + ChildComponentRefs: ["UserRow", "Pagination"], + }); + expect(node.properties.Events[0].PayloadType).toBe("User"); + expect(node.properties.ChildComponentRefs).toEqual(["UserRow", "Pagination"]); + }); + + it("Props/State/Events/ChildComponentRefs default to empty array", () => { + const node = parse({ ComponentName: "X", Description: "x" }); + expect(node.properties.Props).toEqual([]); + expect(node.properties.Events).toEqual([]); + expect(node.properties.ChildComponentRefs).toEqual([]); + }); + + it("Description is required", () => { + const { Description, ...rest } = validProperties; + expect(() => parse(rest)).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/ui-component.schema.ts b/apps/server/src/nodes/schemas/ui-component.schema.ts new file mode 100644 index 0000000..967d1f1 --- /dev/null +++ b/apps/server/src/nodes/schemas/ui-component.schema.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +const PropSchema = z.object({ + Name: z.string().min(1), + Type: z.string().min(1), + Required: z.boolean().default(false), +}).strict(); + +const StateSchema = z.object({ + Name: z.string().min(1), + Type: z.string().min(1), +}).strict(); + +const EventSchema = z.object({ + Name: z.string().min(1), + PayloadType: z.string().optional(), +}).strict(); + +export const UIComponentNodeSchema = BaseNodeSchema.extend({ + type: z.literal("UIComponent"), + properties: z.object({ + ComponentName: z.string().min(1), + Description: z.string().min(1), + Props: z.array(PropSchema).default([]), + State: z.array(StateSchema).default([]), + Events: z.array(EventSchema).default([]), + ChildComponentRefs: z.array(z.string().min(1)).default([]).describe("→ UIComponent node Name'leri"), + }).strict(), +}).strict(); + +export type UIComponentNode = z.infer; diff --git a/apps/server/src/nodes/schemas/version.spec.ts b/apps/server/src/nodes/schemas/version.spec.ts new file mode 100644 index 0000000..a704e0a --- /dev/null +++ b/apps/server/src/nodes/schemas/version.spec.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from "vitest"; +import { GRAPH_SCHEMA_VERSION } from "./version"; + +describe("GRAPH_SCHEMA_VERSION", () => { + it("pozitif integer", () => { + expect(Number.isInteger(GRAPH_SCHEMA_VERSION)).toBe(true); + expect(GRAPH_SCHEMA_VERSION).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/apps/server/src/nodes/schemas/version.ts b/apps/server/src/nodes/schemas/version.ts new file mode 100644 index 0000000..d05f93a --- /dev/null +++ b/apps/server/src/nodes/schemas/version.ts @@ -0,0 +1,5 @@ +/** Node properties schema version. Bumped during enrichment phases. + * v1 = Phase 1 base schemas. v2 = Phase A (Data family codegen-ready). + * v3 = Phase B (Business Logic + Access codegen-ready). + * v4 = Phase C (Infrastructure/Client/Security/Config/Structure codegen-ready). */ +export const GRAPH_SCHEMA_VERSION = 4; diff --git a/apps/server/src/nodes/schemas/view.schema.spec.ts b/apps/server/src/nodes/schemas/view.schema.spec.ts new file mode 100644 index 0000000..648e9f6 --- /dev/null +++ b/apps/server/src/nodes/schemas/view.schema.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { ViewNodeSchema } from "./view.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + ViewName: "active_users_view", + Description: "Returns active users", + Definition: "SELECT id, email FROM users WHERE active = true", + SourceTables: ["users"], + Materialized: false, +}; + +const parse = (properties: unknown) => + ViewNodeSchema.parse({ ...validBase, type: "View", properties }); + +describe("ViewNodeSchema (enriched)", () => { + it("parses valid View", () => { + const node = parse(validProperties); + expect(node.properties.SourceTables).toEqual(["users"]); + }); + + it("defaults Columns to empty array when omitted", () => { + const node = parse(validProperties); + expect(node.properties.Columns).toEqual([]); + }); + + it("accepts Columns + materialized RefreshStrategy", () => { + const node = parse({ + ...validProperties, + Materialized: true, + RefreshStrategy: "scheduled", + Columns: [{ Name: "id", DataType: "UUID" }, { Name: "email", DataType: "VARCHAR" }], + }); + expect(node.properties.RefreshStrategy).toBe("scheduled"); + expect(node.properties.Columns).toHaveLength(2); + }); + + it("rejects invalid RefreshStrategy", () => { + expect(() => parse({ ...validProperties, RefreshStrategy: "always" })).toThrow(); + }); + + it("throws when Definition is empty", () => { + expect(() => parse({ ...validProperties, Definition: "" })).toThrow(); + }); + + it("throws when SourceTables is empty", () => { + expect(() => parse({ ...validProperties, SourceTables: [] })).toThrow(); + }); + + it("throws when Materialized is not boolean", () => { + expect(() => parse({ ...validProperties, Materialized: "no" })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/view.schema.ts b/apps/server/src/nodes/schemas/view.schema.ts new file mode 100644 index 0000000..ba1d3db --- /dev/null +++ b/apps/server/src/nodes/schemas/view.schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +export const ViewNodeSchema = BaseNodeSchema.extend({ + type: z.literal("View"), + properties: z.object({ + ViewName: z.string().min(1), + Description: z.string().min(1), + Definition: z.string().min(1).describe("SQL/aggregate definition"), + SourceTables: z.array(z.string().min(1)).min(1).describe("→ Table Name'leri"), + Materialized: z.boolean(), + Columns: z.array(z.object({ Name: z.string().min(1), DataType: z.string().min(1) })).default([]), + RefreshStrategy: z.enum(["onDemand", "scheduled", "onChange"]).optional().describe("refresh strategy for the materialized view"), + }).strict(), +}).strict(); + +export type ViewNode = z.infer; diff --git a/apps/server/src/nodes/schemas/worker.schema.spec.ts b/apps/server/src/nodes/schemas/worker.schema.spec.ts new file mode 100644 index 0000000..791e97a --- /dev/null +++ b/apps/server/src/nodes/schemas/worker.schema.spec.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { WorkerNodeSchema } from "./worker.schema"; + +const validBase = { + id: "550e8400-e29b-41d4-a716-446655440000", + projectId: "550e8400-e29b-41d4-a716-446655440001", + position: { x: 0, y: 0 }, + createdAt: "2026-05-21T10:30:00.000Z", + updatedAt: "2026-05-21T10:30:00.000Z", +}; + +const validProperties = { + WorkerName: "DailyReportWorker", + Description: "Generates daily report", + Schedule: "0 0 * * *", + TaskToExecute: "generateDailyReport", + TimeoutSeconds: 300, + RetryPolicy: { MaxRetries: 3, BackoffStrategy: "exponential" as const, DelaySeconds: 10 }, +}; + +const parse = (properties: unknown) => + WorkerNodeSchema.parse({ ...validBase, type: "Worker", properties }); + +describe("WorkerNodeSchema (enriched)", () => { + it("parses valid Worker", () => { + const node = parse(validProperties); + expect(node.properties.RetryPolicy.MaxRetries).toBe(3); + expect(node.properties.RetryPolicy.BackoffStrategy).toBe("exponential"); + }); + + it("IsEnabled defaults to true, Concurrency optional", () => { + const node = parse(validProperties); + expect(node.properties.IsEnabled).toBe(true); + expect(node.properties.Concurrency).toBeUndefined(); + }); + + it("RetryPolicy must be object (rejects legacy number)", () => { + expect(() => parse({ ...validProperties, RetryPolicy: 3 })).toThrow(); + }); + + it("rejects invalid BackoffStrategy", () => { + expect(() => parse({ ...validProperties, RetryPolicy: { MaxRetries: 1, BackoffStrategy: "linear" } })).toThrow(); + }); + + it("MaxRetries cannot be negative", () => { + expect(() => parse({ ...validProperties, RetryPolicy: { MaxRetries: -1 } })).toThrow(); + }); + + it("Description is required", () => { + const { Description, ...rest } = validProperties; + expect(() => parse(rest)).toThrow(); + }); + + it("TimeoutSeconds must be positive", () => { + expect(() => parse({ ...validProperties, TimeoutSeconds: 0 })).toThrow(); + }); +}); diff --git a/apps/server/src/nodes/schemas/worker.schema.ts b/apps/server/src/nodes/schemas/worker.schema.ts new file mode 100644 index 0000000..d5c839f --- /dev/null +++ b/apps/server/src/nodes/schemas/worker.schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { BaseNodeSchema } from "./base.schema"; + +const RetryPolicySchema = z.object({ + MaxRetries: z.number().int().nonnegative(), + BackoffStrategy: z.enum(["fixed", "exponential"]).optional(), + DelaySeconds: z.number().int().nonnegative().optional(), +}).strict(); + +export const WorkerNodeSchema = BaseNodeSchema.extend({ + type: z.literal("Worker"), + properties: z.object({ + WorkerName: z.string().min(1), + Description: z.string().min(1), + Schedule: z.string().min(1).describe("cron ifadesi"), + TaskToExecute: z.string().min(1), + TimeoutSeconds: z.number().int().positive(), + RetryPolicy: RetryPolicySchema, + Concurrency: z.number().int().positive().optional(), + IsEnabled: z.boolean().default(true), + }).strict(), +}).strict(); + +export type WorkerNode = z.infer; diff --git a/apps/server/src/nodes/secret-redaction.spec.ts b/apps/server/src/nodes/secret-redaction.spec.ts new file mode 100644 index 0000000..78216f8 --- /dev/null +++ b/apps/server/src/nodes/secret-redaction.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { BadRequestException } from "@nestjs/common"; +import { assertNoPlaintextSecret, redactNodeSecrets } from "./secret-redaction"; + +describe("secret-redaction", () => { + describe("assertNoPlaintextSecret (write)", () => { + it("rejects secret + plain-text DefaultValue (ERR_SECRET_PLAINTEXT)", () => { + let caught: BadRequestException | null = null; + try { + assertNoPlaintextSecret("EnvironmentVariable", { + Key: "AWS_SECRET", + IsSecret: true, + DefaultValue: "AKIA-very-secret", + }); + } catch (e) { + caught = e as BadRequestException; + } + expect(caught).toBeInstanceOf(BadRequestException); + expect((caught!.getResponse() as { code: string }).code).toBe("ERR_SECRET_PLAINTEXT"); + }); + + it("passes when secret but DefaultValue empty/missing", () => { + expect(() => assertNoPlaintextSecret("EnvironmentVariable", { IsSecret: true, DefaultValue: "" })).not.toThrow(); + expect(() => assertNoPlaintextSecret("EnvironmentVariable", { IsSecret: true })).not.toThrow(); + expect(() => assertNoPlaintextSecret("EnvironmentVariable", { IsSecret: true, DefaultValue: " " })).not.toThrow(); + }); + + it("allows DefaultValue when not secret", () => { + expect(() => assertNoPlaintextSecret("EnvironmentVariable", { IsSecret: false, DefaultValue: "3000" })).not.toThrow(); + }); + + it("out of scope for non-EnvironmentVariable types", () => { + expect(() => assertNoPlaintextSecret("Service", { IsSecret: true, DefaultValue: "x" })).not.toThrow(); + }); + }); + + describe("redactNodeSecrets (read)", () => { + it("clears secret DefaultValue on read without mutating input", () => { + const props = { Key: "AWS_SECRET", IsSecret: true, DefaultValue: "AKIA-very-secret" }; + const out = redactNodeSecrets("EnvironmentVariable", props); + expect(out.DefaultValue).toBe(""); + expect(props.DefaultValue).toBe("AKIA-very-secret"); // original unchanged + }); + + it("returns non-secret/other types unchanged", () => { + const a = { IsSecret: false, DefaultValue: "3000" }; + expect(redactNodeSecrets("EnvironmentVariable", a)).toBe(a); + const b = { Foo: "bar" }; + expect(redactNodeSecrets("Service", b)).toBe(b); + }); + }); +}); diff --git a/apps/server/src/nodes/secret-redaction.ts b/apps/server/src/nodes/secret-redaction.ts new file mode 100644 index 0000000..533676a --- /dev/null +++ b/apps/server/src/nodes/secret-redaction.ts @@ -0,0 +1,39 @@ +import { BadRequestException } from "@nestjs/common"; +import type { NodeKind } from "./schemas"; + +/** Only node type that can hold secrets. */ +const SECRET_NODE_KIND = "EnvironmentVariable"; + +function holdsPlaintextSecret(properties: Record | undefined): boolean { + if (!properties) return false; + const dv = properties.DefaultValue; + return properties.IsSecret === true && typeof dv === "string" && dv.trim().length > 0; +} + +/** Write guard: prevents storing plain-text `DefaultValue` (actual secret) on + * EnvironmentVariable with `IsSecret=true`. All write paths (HTTP create/update + + * AI create_node) reach here via NodesService. */ +export function assertNoPlaintextSecret( + type: NodeKind | string, + properties: Record | undefined, +): void { + if (type !== SECRET_NODE_KIND) return; + if (holdsPlaintextSecret(properties)) { + throw new BadRequestException({ + code: "ERR_SECRET_PLAINTEXT", + message: + "When IsSecret=true, DefaultValue (plain-text secret) cannot be stored. " + + "Keep the secret value in a secret manager / env binding; in the node, enter only Key and Description.", + }); + } +} + +/** Read guard: never return secret EnvironmentVariable `DefaultValue` to API/AI/codegen + * (legacy data may remain from before the write guard). Returns a new object; input is not mutated. */ +export function redactNodeSecrets( + type: NodeKind | string, + properties: Record, +): Record { + if (type !== SECRET_NODE_KIND || !holdsPlaintextSecret(properties)) return properties; + return { ...properties, DefaultValue: "" }; +} diff --git a/apps/server/src/nodes/validate-properties.spec.ts b/apps/server/src/nodes/validate-properties.spec.ts new file mode 100644 index 0000000..6a52a59 --- /dev/null +++ b/apps/server/src/nodes/validate-properties.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { BadRequestException } from "@nestjs/common"; +import { validateNodeProperties } from "./validate-properties"; + +describe("validateNodeProperties", () => { + it("parses valid properties and applies defaults", () => { + const out = validateNodeProperties("EnvironmentVariable", { + Key: "PORT", + Description: "application port", + DataType: "Number", + IsSecret: false, + Environment: ["Dev"], + }); + expect(out.Key).toBe("PORT"); + expect(out.IsRequired).toBe(true); // schema default + }); + + it("invalid properties → ERR_SCHEMA_INVALID + field details", () => { + let caught: BadRequestException | null = null; + try { + validateNodeProperties("EnvironmentVariable", { Description: "missing Key", DataType: "Number" }); + } catch (e) { + caught = e as BadRequestException; + } + expect(caught).toBeInstanceOf(BadRequestException); + const body = caught!.getResponse() as { code: string; details: Array<{ field: string }> }; + expect(body.code).toBe("ERR_SCHEMA_INVALID"); + expect(Array.isArray(body.details)).toBe(true); + expect(body.details.length).toBeGreaterThan(0); + }); + + it("rejects extra fields (strict)", () => { + expect(() => + validateNodeProperties("EnvironmentVariable", { + Key: "X", + Description: "x", + DataType: "String", + IsSecret: false, + Environment: ["Dev"], + Unexpected: "field", + }), + ).toThrow(BadRequestException); + }); + + it("unknown kind → ERR_UNKNOWN_KIND", () => { + let caught: BadRequestException | null = null; + try { + validateNodeProperties("Nope", {}); + } catch (e) { + caught = e as BadRequestException; + } + expect((caught!.getResponse() as { code: string }).code).toBe("ERR_UNKNOWN_KIND"); + }); +}); diff --git a/apps/server/src/nodes/validate-properties.ts b/apps/server/src/nodes/validate-properties.ts new file mode 100644 index 0000000..249cc1d --- /dev/null +++ b/apps/server/src/nodes/validate-properties.ts @@ -0,0 +1,33 @@ +import { BadRequestException } from "@nestjs/common"; +import { PROPERTIES_SCHEMA_BY_KIND, type NodeKind } from "./schemas"; + +/** Validates a node's properties with the kind-specific Zod schema and returns the **parsed** + * object (defaults applied, extras rejected). + * + * All write paths go through here: HTTP PATCH (update) + AI create_node. Invalid + * input is rejected with `ERR_SCHEMA_INVALID` + field-level `details`; the AI agent + * loop reads this body and self-corrects (ReAct). */ +export function validateNodeProperties( + kind: NodeKind | string, + properties: unknown, +): Record { + const schema = PROPERTIES_SCHEMA_BY_KIND[kind as NodeKind]; + if (!schema) { + throw new BadRequestException({ + code: "ERR_UNKNOWN_KIND", + message: `Unknown node type: '${kind}'.`, + }); + } + const result = schema.safeParse(properties); + if (!result.success) { + throw new BadRequestException({ + code: "ERR_SCHEMA_INVALID", + message: `The properties of the '${kind}' node do not match the schema.`, + details: result.error.issues.map((i) => ({ + field: i.path.join(".") || "(root)", + message: i.message, + })), + }); + } + return result.data as Record; +} diff --git a/apps/server/src/patterns/dto/create-pattern.dto.ts b/apps/server/src/patterns/dto/create-pattern.dto.ts new file mode 100644 index 0000000..d60df88 --- /dev/null +++ b/apps/server/src/patterns/dto/create-pattern.dto.ts @@ -0,0 +1,4 @@ +import { createZodDto } from "nestjs-zod"; +import { CreatePatternSchema } from "../schemas/pattern.schema"; + +export class CreatePatternDto extends createZodDto(CreatePatternSchema) {} diff --git a/apps/server/src/patterns/dto/promote-pattern.dto.ts b/apps/server/src/patterns/dto/promote-pattern.dto.ts new file mode 100644 index 0000000..b57e26a --- /dev/null +++ b/apps/server/src/patterns/dto/promote-pattern.dto.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import { createZodDto } from "nestjs-zod"; + +export const PromotePatternSchema = z.object({ + name: z.string().min(1), + description: z.string().min(1), + tags: z.array(z.string().min(1)).default([]), + nodeIds: z.array(z.string().uuid()).optional(), +}).strict(); + +export type PromotePatternInput = z.infer; + +export class PromotePatternDto extends createZodDto(PromotePatternSchema) {} diff --git a/apps/server/src/patterns/dto/search-pattern.dto.ts b/apps/server/src/patterns/dto/search-pattern.dto.ts new file mode 100644 index 0000000..a6b4549 --- /dev/null +++ b/apps/server/src/patterns/dto/search-pattern.dto.ts @@ -0,0 +1,4 @@ +import { createZodDto } from "nestjs-zod"; +import { SearchPatternSchema } from "../schemas/pattern.schema"; + +export class SearchPatternDto extends createZodDto(SearchPatternSchema) {} diff --git a/apps/server/src/patterns/patterns.controller.spec.ts b/apps/server/src/patterns/patterns.controller.spec.ts new file mode 100644 index 0000000..d522829 --- /dev/null +++ b/apps/server/src/patterns/patterns.controller.spec.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, vi } from "vitest"; +import { PatternsController } from "./patterns.controller"; + +describe("PatternsController (read only — seed)", () => { + const service = { + search: vi.fn().mockResolvedValue([]), + list: vi.fn().mockResolvedValue([]), + getById: vi.fn().mockResolvedValue({ id: "1" }), + }; + const c = new PatternsController(service as any); + +it("list envelope returns", async () => { + const r = await c.list(); + expect(r).toEqual({ success: true, data: [] }); + }); + +it("search passes default k/minScore", async () => { + await c.search({ query: "x" } as any); + expect(service.search).toHaveBeenCalledWith("x", expect.any(Number), expect.any(Number)); + }); + +it("write ends (create/delete/promote) removed — BOLA closed", () => { + expect((c as unknown as { create?: unknown }).create).toBeUndefined(); + expect((c as unknown as { delete?: unknown }).delete).toBeUndefined(); + expect((c as unknown as { promote?: unknown }).promote).toBeUndefined(); + }); +}); diff --git a/apps/server/src/patterns/patterns.controller.ts b/apps/server/src/patterns/patterns.controller.ts new file mode 100644 index 0000000..6f8ce70 --- /dev/null +++ b/apps/server/src/patterns/patterns.controller.ts @@ -0,0 +1,47 @@ +import { Body, Controller, Get, HttpCode, Param, Post, UseGuards } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiResponse, ApiParam } from "@nestjs/swagger"; +import { ProjectAccessGuard } from "../auth/project-access.guard"; +import { PatternsService } from "./patterns.service"; +import { SearchPatternDto } from "./dto/search-pattern.dto"; +import { ok } from "../common/envelope"; +import { env } from "../config/env"; + +/* SECURITY (launch): Pattern library is currently READ ONLY + canonical 'seed' +* Limited to patterns. REMOVED create/delete/promote write bits — +* :Pattern node has no tenant (ownerId/orgId) stamp so these ends +* was a cross-tenant read/delete/prompt-poison vulnerability (BOLA). writing + tenant +* Ownership will be added post-launch. Seeding is done with `pnpm seed:patterns` +* (Calls PatternsService directly, not through this controller). Reading ways +* Scoped in the repository with source:'seed'. */ +@ApiTags("Patterns (GraphRAG)") +@UseGuards(ProjectAccessGuard) +@Controller() +export class PatternsController { + constructor(private readonly service: PatternsService) {} + + @Get("patterns") + @ApiOperation({ summary: "Pattern list (seed only)", description: "Summary of canonical seed patterns (including node/edge counts)." }) + async list() { + return ok(await this.service.list()); + } + + @Get("patterns/:id") + @ApiOperation({ summary: "Single pattern (seed only)", description: "Full pattern including graphJson (sub-graph)." }) + @ApiParam({ name: "id", description: "Pattern UUID" }) + @ApiResponse({ status: 404, description: "ERR_PATTERN_NOT_FOUND" }) + async getById(@Param("id") id: string) { + return ok(await this.service.getById(id)); + } + + @Post("patterns/search") + @HttpCode(200) + @ApiOperation({ + summary: "Semantic pattern search (seed only)", + description: "Embeds the query and returns top-K cosine matches from the native vector index over seed patterns. Empty list if embedding is unavailable.", + }) + @ApiResponse({ status: 200, description: "[{ pattern, score }] sorted by similarity." }) + async search(@Body() body: SearchPatternDto) { + const { query, k, minScore } = body as any; + return ok(await this.service.search(query, k ?? env.EMBED_TOP_K, minScore ?? env.EMBED_MIN_SCORE)); + } +} diff --git a/apps/server/src/patterns/patterns.module.ts b/apps/server/src/patterns/patterns.module.ts new file mode 100644 index 0000000..0bc1218 --- /dev/null +++ b/apps/server/src/patterns/patterns.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { Neo4jModule } from "../neo4j/neo4j.module"; +import { ProjectsModule } from "../projects/projects.module"; +import { EmbeddingsModule } from "../embeddings/embeddings.module"; +import { PatternsController } from "./patterns.controller"; +import { PatternsService } from "./patterns.service"; +import { PatternsRepository } from "./patterns.repository"; + +@Module({ + imports: [Neo4jModule, ProjectsModule, EmbeddingsModule], + controllers: [PatternsController], + providers: [PatternsService, PatternsRepository], + exports: [PatternsService], +}) +export class PatternsModule {} diff --git a/apps/server/src/patterns/patterns.repository.spec.ts b/apps/server/src/patterns/patterns.repository.spec.ts new file mode 100644 index 0000000..10e4f98 --- /dev/null +++ b/apps/server/src/patterns/patterns.repository.spec.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from "vitest"; +import { PatternsRepository } from "./patterns.repository"; + +const neo4j = { run: vi.fn() }; +const repo = new PatternsRepository(neo4j as any); + +const props = { + id: "1", name: "n", description: "d", tags: [], + graphJson: '{"nodes":[{"tempId":"t","type":"Controller","properties":{}}],"edges":[]}', + source: "seed", createdAt: "2026-05-22T00:00:00.000Z", +}; + +describe("PatternsRepository", () => { +it("search calls vector index and hit maps", async () => { + neo4j.run.mockResolvedValueOnce({ + records: [{ get: (k: string) => (k === "score" ? 0.91 : { properties: props }) }], + }); + const hits = await repo.search([0.1, 0.2], 3, 0.7); + expect(hits[0].score).toBe(0.91); + expect(hits[0].pattern.graph.nodes).toHaveLength(1); + expect(neo4j.run.mock.calls[0][0]).toContain("db.index.vector.queryNodes"); + expect(neo4j.run.mock.calls[0][1]).toEqual({ k: 3, embedding: [0.1, 0.2], minScore: 0.7 }); + }); + +it("getById otherwise returns null", async () => { + neo4j.run.mockResolvedValueOnce({ records: [] }); + expect(await repo.getById("x")).toBeNull(); + }); + +it("list summary (nodeCount/edgeCount) returns", async () => { + neo4j.run.mockResolvedValueOnce({ records: [{ get: () => ({ properties: props }) }] }); + const list = await repo.list(); + expect(list[0].nodeCount).toBe(1); + expect(list[0].edgeCount).toBe(0); + }); +}); diff --git a/apps/server/src/patterns/patterns.repository.ts b/apps/server/src/patterns/patterns.repository.ts new file mode 100644 index 0000000..c7be065 --- /dev/null +++ b/apps/server/src/patterns/patterns.repository.ts @@ -0,0 +1,107 @@ +import { Injectable } from "@nestjs/common"; +import { Neo4jService } from "../neo4j/neo4j.service"; +import type { StoredPattern, PatternSummary } from "./schemas/pattern.schema"; + +export interface PatternSearchHit { + pattern: StoredPattern; + score: number; +} + +@Injectable() +export class PatternsRepository { + constructor(private readonly neo4j: Neo4jService) {} + + async create(p: StoredPattern, embedding: number[]): Promise { + await this.neo4j.run( + `CREATE (p:Pattern { + id: $id, name: $name, description: $description, tags: $tags, + graphJson: $graphJson, source: $source, + createdAt: datetime($createdAt), embedding: $embedding + })`, + { + id: p.id, + name: p.name, + description: p.description, + tags: p.tags, + graphJson: JSON.stringify(p.graph), + source: p.source, + createdAt: p.createdAt, + embedding, + }, + ); + } + +// SECURITY (inter-tenant BOLA): reading surface canonical 'seed' patterns ONLY +//returns. Since promoted (user) patterns are not stamped to the tenant +// does not leak out of any read path (list/getById/search) + to AI prompt + // (search RAG) zehirli pattern enjekte edilemez. + async list(): Promise { + const res = await this.neo4j.run( + `MATCH (p:Pattern {source: 'seed'}) RETURN p ORDER BY p.createdAt DESC`, + ); + return res.records.map((r) => toSummary(r.get("p").properties)); + } + + async getById(id: string): Promise { + const res = await this.neo4j.run(`MATCH (p:Pattern {id: $id, source: 'seed'}) RETURN p`, { id }); + if (res.records.length === 0) return null; + return toStored(res.records[0].get("p").properties); + } + + async delete(id: string): Promise { + const res = await this.neo4j.run( + `MATCH (p:Pattern {id: $id}) DELETE p RETURN 1 AS d`, + { id }, + ); + return res.records.length > 0; + } + + async findByName(name: string): Promise { + const res = await this.neo4j.run( + `MATCH (p:Pattern {name: $name}) RETURN p LIMIT 1`, + { name }, + ); + return res.records.length > 0; + } + +/** Native vector search: cosine top-K + minScore filter. */ + async search(embedding: number[], k: number, minScore: number): Promise { + const res = await this.neo4j.run( + `CALL db.index.vector.queryNodes('pattern_embedding', $k, $embedding) + YIELD node, score + WHERE node.source = 'seed' AND score >= $minScore + RETURN node, score ORDER BY score DESC`, + { k: Math.trunc(k), embedding, minScore }, + ); + return res.records.map((r) => ({ + pattern: toStored(r.get("node").properties), + score: r.get("score"), + })); + } +} + +function toStored(p: any): StoredPattern { + return { + id: p.id, + name: p.name, + description: p.description, + tags: p.tags ?? [], + graph: JSON.parse(p.graphJson), + source: p.source, + createdAt: new Date(p.createdAt).toISOString(), + }; +} + +function toSummary(p: any): PatternSummary { + const g = JSON.parse(p.graphJson); + return { + id: p.id, + name: p.name, + description: p.description, + tags: p.tags ?? [], + source: p.source, + createdAt: new Date(p.createdAt).toISOString(), + nodeCount: g.nodes.length, + edgeCount: g.edges.length, + }; +} diff --git a/apps/server/src/patterns/patterns.service.spec.ts b/apps/server/src/patterns/patterns.service.spec.ts new file mode 100644 index 0000000..826a098 --- /dev/null +++ b/apps/server/src/patterns/patterns.service.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from "vitest"; +import { PatternsService } from "./patterns.service"; + +const graph = { nodes: [{ tempId: "t", type: "Controller", properties: {} }], edges: [] }; + +function make(embConfigured = true) { + const repo = { + create: vi.fn(), list: vi.fn(), getById: vi.fn(), + delete: vi.fn(), search: vi.fn().mockResolvedValue([]), + }; + const projectsRepo = { getById: vi.fn(), getGraph: vi.fn() }; + const embeddings = { + isConfigured: () => embConfigured, + embed: vi.fn().mockResolvedValue([0.1, 0.2]), + embedBatch: vi.fn(), + }; + return { svc: new PatternsService(repo as any, projectsRepo as any, embeddings as any), repo, projectsRepo, embeddings }; +} + +describe("PatternsService", () => { +it("create embeds and calls repo.create", async () => { + const { svc, repo, embeddings } = make(); + await svc.create({ name: "n", description: "d", tags: [], graph } as any); + expect(embeddings.embed).toHaveBeenCalled(); + expect(repo.create).toHaveBeenCalledWith( + expect.objectContaining({ name: "n", source: "seed" }), + [0.1, 0.2], + ); + }); + +it("If there is no embedding, search returns empty (gradient)", async () => { + const { svc, embeddings } = make(false); + expect(await svc.search("x", 3, 0.7)).toEqual([]); + expect(embeddings.embed).not.toHaveBeenCalled(); + }); + +it("If there is no embedding, create throws 503", async () => { + const { svc } = make(false); + await expect(svc.create({ name: "n", description: "d", tags: [], graph } as any)).rejects.toThrow(); + }); + + it("promote: olmayan proje 404", async () => { + const { svc, projectsRepo } = make(); + projectsRepo.getById.mockResolvedValue(null); + await expect(svc.promoteFromProject("p", { name: "n", description: "d" })).rejects.toThrow(); + }); + +it("promote: tempIds and creates the entire project graph", async () => { + const { svc, projectsRepo, repo } = make(); + projectsRepo.getById.mockResolvedValue({ id: "p" }); + projectsRepo.getGraph.mockResolvedValue({ + nodes: [{ id: "n1", type: "Controller", properties: { ControllerName: "X" } }], + edges: [{ sourceNodeId: "n1", targetNodeId: "n1", kind: "CALLS", properties: {} }], + }); + await svc.promoteFromProject("p", { name: "P", description: "d" }); + const stored = repo.create.mock.calls[0][0]; + expect(stored.source).toBe("promoted"); + expect(stored.graph.nodes[0].tempId).toMatch(/^t_0_/); + expect(stored.graph.edges[0].sourceTempId).toBe(stored.graph.nodes[0].tempId); + }); +}); diff --git a/apps/server/src/patterns/patterns.service.ts b/apps/server/src/patterns/patterns.service.ts new file mode 100644 index 0000000..3b22dc7 --- /dev/null +++ b/apps/server/src/patterns/patterns.service.ts @@ -0,0 +1,111 @@ +import { Injectable, Inject, NotFoundException, ServiceUnavailableException } from "@nestjs/common"; +import { randomUUID } from "node:crypto"; +import { PatternsRepository, type PatternSearchHit } from "./patterns.repository"; +import { ProjectsRepository } from "../projects/projects.repository"; +import { EMBEDDINGS, type IEmbeddings } from "../embeddings/embeddings.types"; +import type { CreatePatternInput, StoredPattern, PatternSummary, PatternGraph } from "./schemas/pattern.schema"; + +@Injectable() +export class PatternsService { + constructor( + private readonly repo: PatternsRepository, + private readonly projectsRepo: ProjectsRepository, + @Inject(EMBEDDINGS) private readonly embeddings: IEmbeddings, + ) {} + + private embedText(p: { name: string; description: string; tags: string[] }): string { + return `${p.name}\n${p.description}\n${p.tags.join(" ")}`; + } + + async create(input: CreatePatternInput, source: "seed" | "promoted" = "seed"): Promise { + this.assertEmbeddings(); + const stored: StoredPattern = { + id: randomUUID(), + name: input.name, + description: input.description, + tags: input.tags, + graph: input.graph, + source, + createdAt: new Date().toISOString(), + }; + const vec = await this.embeddings.embed(this.embedText(stored)); + await this.repo.create(stored, vec); + return summarize(stored); + } + + list(): Promise { + return this.repo.list(); + } + + async getById(id: string): Promise { + const p = await this.repo.getById(id); + if (!p) throw new NotFoundException({ code: "ERR_PATTERN_NOT_FOUND", message: `Pattern '${id}' not found.` }); + return p; + } + + async delete(id: string): Promise { + if (!(await this.repo.delete(id))) + throw new NotFoundException({ code: "ERR_PATTERN_NOT_FOUND", message: `Pattern '${id}' not found.` }); + } + +/** Embeds the query and returns top-K. Empty (gradient) if embedding is not configured. */ + async search(query: string, k: number, minScore: number): Promise { + if (!this.embeddings.isConfigured()) return []; + const vec = await this.embeddings.embed(query); + return this.repo.search(vec, k, minScore); + } + +/** Promote pattern from project graph. If nodeIds are not given, the entire project. */ + async promoteFromProject( + projectId: string, + input: { name: string; description: string; tags?: string[]; nodeIds?: string[] }, + ): Promise { + const project = await this.projectsRepo.getById(projectId); + if (!project) + throw new NotFoundException({ code: "ERR_PROJECT_NOT_FOUND", message: `Project '${projectId}' not found.` }); + + const { nodes, edges } = await this.projectsRepo.getGraph(projectId); + const selected = input.nodeIds?.length + ? nodes.filter((n: any) => input.nodeIds!.includes(n.id)) + : nodes; + if (selected.length === 0) + throw new NotFoundException({ code: "ERR_PATTERN_NODE_NOT_FOUND", message: "The selected nodeIds were not found in the project." }); + +// actual id → tempId; Only edges between selected nodes are moved. + const idToTemp = new Map(); + selected.forEach((n: any, i: number) => idToTemp.set(n.id, `t_${i}_${String(n.type).toLowerCase()}`)); + const graph: PatternGraph = { + nodes: selected.map((n: any) => ({ tempId: idToTemp.get(n.id)!, type: n.type, properties: n.properties })), + edges: edges + .filter((e: any) => idToTemp.has(e.sourceNodeId) && idToTemp.has(e.targetNodeId)) + .map((e: any) => ({ + sourceTempId: idToTemp.get(e.sourceNodeId)!, + targetTempId: idToTemp.get(e.targetNodeId)!, + edgeType: e.kind, + label: e.properties?.Label, + })), + }; + return this.create({ name: input.name, description: input.description, tags: input.tags ?? [], graph }, "promoted"); + } + + private assertEmbeddings(): void { + if (!this.embeddings.isConfigured()) + throw new ServiceUnavailableException({ + code: "ERR_EMBEDDINGS_NOT_CONFIGURED", + message: "Embedding provider is not configured.", + }); + } +} + +function summarize(p: StoredPattern): PatternSummary { + return { + id: p.id, + name: p.name, + description: p.description, + tags: p.tags, + source: p.source, + createdAt: p.createdAt, + nodeCount: p.graph.nodes.length, + edgeCount: p.graph.edges.length, + }; +} diff --git a/apps/server/src/patterns/schemas/pattern.schema.spec.ts b/apps/server/src/patterns/schemas/pattern.schema.spec.ts new file mode 100644 index 0000000..34d275b --- /dev/null +++ b/apps/server/src/patterns/schemas/pattern.schema.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { CreatePatternSchema, PatternGraphSchema } from "./pattern.schema"; + +const graph = { + nodes: [{ tempId: "t_ctrl", type: "Controller", properties: { ControllerName: "X" } }], + edges: [], +}; + +describe("CreatePatternSchema", () => { + it("parses valid pattern, tags default empty", () => { + const p = CreatePatternSchema.parse({ name: "n", description: "d", graph }); + expect(p.tags).toEqual([]); + expect(p.graph.nodes).toHaveLength(1); + expect(p.graph.edges).toEqual([]); + }); + + it("throws when graph.nodes is empty", () => { + expect(() => PatternGraphSchema.parse({ nodes: [], edges: [] })).toThrow(); + }); + + it("accepts edge with valid edgeType", () => { + const g = PatternGraphSchema.parse({ + nodes: graph.nodes, + edges: [{ sourceTempId: "a", targetTempId: "b", edgeType: "CALLS" }], + }); + expect(g.edges[0].edgeType).toBe("CALLS"); + }); + + it("rejects invalid edgeType", () => { + expect(() => PatternGraphSchema.parse({ + nodes: graph.nodes, + edges: [{ sourceTempId: "a", targetTempId: "b", edgeType: "BOGUS" }], + })).toThrow(); + }); + + it("rejects unknown top-level field (strict)", () => { + expect(() => CreatePatternSchema.parse({ name: "n", description: "d", graph, extra: 1 })).toThrow(); + }); +}); diff --git a/apps/server/src/patterns/schemas/pattern.schema.ts b/apps/server/src/patterns/schemas/pattern.schema.ts new file mode 100644 index 0000000..0d2af09 --- /dev/null +++ b/apps/server/src/patterns/schemas/pattern.schema.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; +import { EdgeKindSchema } from "../../edges/schemas/edge.schema"; + +// graphJson: SAME format as GraphService.apply input (tempId-based) -> pattern +// can be applied directly later (Phase 5). +export const PatternGraphSchema = z.object({ + nodes: z.array(z.object({ + tempId: z.string().min(1), + type: z.string().min(1), + properties: z.record(z.unknown()), + }).strict()).min(1), + edges: z.array(z.object({ + sourceTempId: z.string().min(1), + targetTempId: z.string().min(1), + edgeType: EdgeKindSchema, + label: z.string().optional(), + }).strict()).default([]), +}).strict(); + +export const CreatePatternSchema = z.object({ + name: z.string().min(1), + description: z.string().min(1), + tags: z.array(z.string().min(1)).default([]), + graph: PatternGraphSchema, +}).strict(); + +export const SearchPatternSchema = z.object({ + query: z.string().min(1), + k: z.number().int().positive().max(20).optional(), + minScore: z.number().min(0).max(1).optional(), +}).strict(); + +export type PatternGraph = z.infer; +export type CreatePatternInput = z.infer; +export type SearchPatternInput = z.infer; + +/** Full stored + returned Pattern (embedding not included in API response). */ +export interface StoredPattern { + id: string; + name: string; + description: string; + tags: string[]; + graph: PatternGraph; + source: "seed" | "promoted"; + createdAt: string; +} + +export interface PatternSummary { + id: string; + name: string; + description: string; + tags: string[]; + source: string; + createdAt: string; + nodeCount: number; + edgeCount: number; +} diff --git a/apps/server/src/patterns/seed/canonical-patterns.ts b/apps/server/src/patterns/seed/canonical-patterns.ts new file mode 100644 index 0000000..c771590 --- /dev/null +++ b/apps/server/src/patterns/seed/canonical-patterns.ts @@ -0,0 +1,202 @@ +import type { CreatePatternInput } from "../schemas/pattern.schema"; + +/** Canonical architecture patterns to seed. Node properties follow the enriched + * v4 schemas, edges conform to EdgeKindSchema. graphJson = GraphService.apply format. */ +export const CANONICAL_PATTERNS: CreatePatternInput[] = [ + { + name: "Layered CRUD (Controller→Service→Repository→Table)", + description: "Standard REST CRUD: the Controller receives the HTTP request, the Service runs the business logic, the Repository accesses the data, and the Table holds the data. The most common layered backend architecture.", + tags: ["crud", "layered", "rest", "backend"], + graph: { + nodes: [ + { tempId: "t_ctrl", type: "Controller", properties: { ControllerName: "ResourceController", Description: "Kaynak REST API", BaseRoute: "/api/v1/resources", Endpoints: [{ HttpMethod: "POST", Route: "/", RequestDTORef: "CreateResourceDTO", ResponseDTORef: "ResourceDTO", RequiresAuth: true }] } }, + { tempId: "t_svc", type: "Service", properties: { ServiceName: "ResourceService", Description: "Resource business logic", IsTransactionScoped: true, Methods: [{ MethodName: "create", Parameters: [{ Name: "dto", Type: "CreateResourceDTO", DtoRef: "CreateResourceDTO" }], ReturnType: "ResourceDTO", ReturnDtoRef: "ResourceDTO", IsAsync: true }], Dependencies: [{ Kind: "Repository", Ref: "ResourceRepository" }] } }, + { tempId: "t_repo", type: "Repository", properties: { RepositoryName: "ResourceRepository", Description: "Resource data access", EntityReference: "resources", IsCached: false, CustomQueries: [] } }, + { tempId: "t_tbl", type: "Table", properties: { TableName: "resources", Description: "Kaynak tablosu", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }] } }, + { tempId: "t_dto", type: "DTO", properties: { Name: "CreateResourceDTO", Description: "Resource creation request", Fields: [{ Name: "name", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [{ Rule: "MinLength", Value: "1" }] }] } }, + ], + edges: [ + { sourceTempId: "t_ctrl", targetTempId: "t_svc", edgeType: "CALLS" }, + { sourceTempId: "t_svc", targetTempId: "t_repo", edgeType: "CALLS" }, + { sourceTempId: "t_repo", targetTempId: "t_tbl", edgeType: "WRITES" }, + { sourceTempId: "t_ctrl", targetTempId: "t_dto", edgeType: "USES" }, + ], + }, + }, + { + name: "Cache-aside (Service→Cache + Service→Repository)", + description: "Cache-aside to reduce read load: the Service checks the Cache first, and if absent reads from the Repository and writes to the Cache. Redis with TTL, LRU eviction.", + tags: ["cache", "performance", "read-heavy", "redis"], + graph: { + nodes: [ + { tempId: "t_svc", type: "Service", properties: { ServiceName: "ProfileService", Description: "Profil okuma", IsTransactionScoped: false, Methods: [{ MethodName: "getProfile", Parameters: [{ Name: "id", Type: "UUID" }], ReturnType: "ProfileDTO", IsAsync: true }], Dependencies: [{ Kind: "Cache", Ref: "ProfileCache" }, { Kind: "Repository", Ref: "ProfileRepository" }] } }, + { tempId: "t_cache", type: "Cache", properties: { CacheName: "ProfileCache", Description: "Profil cache", KeyPattern: "profile:{id}", TTL_Seconds: 3600, Engine: "Redis", EvictionPolicy: "LRU" } }, + { tempId: "t_repo", type: "Repository", properties: { RepositoryName: "ProfileRepository", Description: "Profile data access", EntityReference: "profiles", IsCached: true, CustomQueries: [] } }, + ], + edges: [ + { sourceTempId: "t_svc", targetTempId: "t_cache", edgeType: "CACHES_IN" }, + { sourceTempId: "t_svc", targetTempId: "t_repo", edgeType: "CALLS" }, + ], + }, + }, + { + name: "JWT authentication flow (Auth)", + description: "JWT-based login: the AuthController receives the login request, the AuthService authenticates and issues a token, JWT_SECRET is read from the env, and the AuthMiddleware guards protected routes.", + tags: ["auth", "jwt", "security", "login"], + graph: { + nodes: [ + { tempId: "t_ctrl", type: "Controller", properties: { ControllerName: "AuthController", Description: "Authentication API", BaseRoute: "/api/v1/auth", Endpoints: [{ HttpMethod: "POST", Route: "/login", RequestDTORef: "LoginDTO", ResponseDTORef: "TokenDTO", RequiresAuth: false }] } }, + { tempId: "t_svc", type: "Service", properties: { ServiceName: "AuthService", Description: "Token generation + validation", IsTransactionScoped: false, Methods: [{ MethodName: "login", Parameters: [{ Name: "dto", Type: "LoginDTO", DtoRef: "LoginDTO" }], ReturnType: "TokenDTO", ReturnDtoRef: "TokenDTO", IsAsync: true, Throws: ["UnauthorizedException"] }], Dependencies: [{ Kind: "Repository", Ref: "UserRepository" }] } }, + { tempId: "t_mw", type: "Middleware", properties: { MiddlewareName: "AuthMiddleware", Description: "JWT validation middleware", AppliesTo: "SpecificRoutes", ExecutionOrder: 1, MiddlewareType: "Auth" } }, + { tempId: "t_env", type: "EnvironmentVariable", properties: { Key: "JWT_SECRET", Description: "JWT signing key", DataType: "String", IsSecret: true, Environment: ["Dev", "Staging", "Prod"], IsRequired: true } }, + { tempId: "t_dto", type: "DTO", properties: { Name: "LoginDTO", Description: "Login request", Fields: [{ Name: "email", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [{ Rule: "Email" }] }, { Name: "password", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [{ Rule: "MinLength", Value: "8" }] }] } }, + ], + edges: [ + { sourceTempId: "t_ctrl", targetTempId: "t_svc", edgeType: "CALLS" }, + { sourceTempId: "t_ctrl", targetTempId: "t_dto", edgeType: "USES" }, + { sourceTempId: "t_svc", targetTempId: "t_env", edgeType: "READS_CONFIG" }, + ], + }, + }, + { + name: "Saga payment orchestration (distributed transaction)", + description: "A Saga that coordinates a distributed transaction: the Orchestrator runs the steps stock reservation → payment → shipment in order; on failure it rolls back with compensation actions.", + tags: ["saga", "orchestration", "distributed-transaction", "payment"], + graph: { + nodes: [ + { tempId: "t_orch", type: "Orchestrator", properties: { OrchestratorName: "OrderSaga", Description: "Order Saga coordination", Pattern: "Saga", Steps: [{ StepName: "reserveStock", ServiceRef: "InventoryService", Action: "reserve", CompensationAction: "release", OnFailure: "compensate" }, { StepName: "charge", ServiceRef: "PaymentService", Action: "charge", CompensationAction: "refund", OnFailure: "compensate" }] } }, + { tempId: "t_inv", type: "Service", properties: { ServiceName: "InventoryService", Description: "Inventory management", IsTransactionScoped: true, Methods: [{ MethodName: "reserve", ReturnType: "void", IsAsync: true }] } }, + { tempId: "t_pay", type: "Service", properties: { ServiceName: "PaymentService", Description: "Payment processing", IsTransactionScoped: true, Methods: [{ MethodName: "charge", ReturnType: "void", IsAsync: true }] } }, + ], + edges: [ + { sourceTempId: "t_orch", targetTempId: "t_inv", edgeType: "CALLS" }, + { sourceTempId: "t_orch", targetTempId: "t_pay", edgeType: "CALLS" }, + ], + }, + }, + { + name: "CQRS — command/query separation", + description: "Separates the write and read paths: the CommandService applies writes to the Table, the QueryService returns reads from a materialized View. For read scaling.", + tags: ["cqrs", "read-model", "scalability"], + graph: { + nodes: [ + { tempId: "t_cmd_ctrl", type: "Controller", properties: { ControllerName: "OrderCommandController", Description: "Order write API", BaseRoute: "/api/v1/orders", Endpoints: [{ HttpMethod: "POST", Route: "/", RequestDTORef: "CreateOrderDTO", RequiresAuth: true }] } }, + { tempId: "t_cmd_svc", type: "Service", properties: { ServiceName: "OrderCommandService", Description: "Order write logic", IsTransactionScoped: true, Methods: [{ MethodName: "create", ReturnType: "void", IsAsync: true }], Dependencies: [{ Kind: "Repository", Ref: "OrderRepository" }] } }, + { tempId: "t_repo", type: "Repository", properties: { RepositoryName: "OrderRepository", Description: "Order write repository", EntityReference: "orders", IsCached: false, CustomQueries: [] } }, + { tempId: "t_tbl", type: "Table", properties: { TableName: "orders", Description: "Orders table", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }] } }, + { tempId: "t_qry_svc", type: "Service", properties: { ServiceName: "OrderQueryService", Description: "Order read logic", IsTransactionScoped: false, Methods: [{ MethodName: "listSummaries", ReturnType: "OrderSummaryDTO[]", IsAsync: true }] } }, + { tempId: "t_view", type: "View", properties: { ViewName: "order_summary_view", Description: "Order summary read model", Definition: "SELECT id, total, status FROM orders", SourceTables: ["orders"], Materialized: true, RefreshStrategy: "onChange" } }, + ], + edges: [ + { sourceTempId: "t_cmd_ctrl", targetTempId: "t_cmd_svc", edgeType: "CALLS" }, + { sourceTempId: "t_cmd_svc", targetTempId: "t_repo", edgeType: "CALLS" }, + { sourceTempId: "t_repo", targetTempId: "t_tbl", edgeType: "WRITES" }, + { sourceTempId: "t_qry_svc", targetTempId: "t_view", edgeType: "QUERIES" }, + ], + }, + }, + { + name: "Event-driven (publish/subscribe)", + description: "Loosely coupled asynchronous processing: a Service publishes an event to a MessageQueue, and an EventHandler subscribes and processes it. Removes the synchronous dependency.", + tags: ["event-driven", "async", "pubsub", "messaging"], + graph: { + nodes: [ + { tempId: "t_svc", type: "Service", properties: { ServiceName: "OrderService", Description: "Order creation", IsTransactionScoped: true, Methods: [{ MethodName: "place", ReturnType: "void", IsAsync: true }] } }, + { tempId: "t_mq", type: "MessageQueue", properties: { QueueName: "order-events", Description: "Order events", Type: "Topic", Provider: "Kafka", MessageFormat: "OrderEventDTO", DeliveryGuarantee: "at-least-once", DeadLetterQueue: "order-events-dlq" } }, + { tempId: "t_handler", type: "EventHandler", properties: { HandlerName: "OrderPlacedHandler", Description: "Post-order notification", EventName: "ORDER_PLACED", IsAsync: true, QueueRef: "order-events", RetryPolicy: { MaxRetries: 5, DelaySeconds: 30 }, DeadLetterQueue: "order-events-dlq" } }, + ], + edges: [ + { sourceTempId: "t_svc", targetTempId: "t_mq", edgeType: "PUBLISHES" }, + { sourceTempId: "t_handler", targetTempId: "t_mq", edgeType: "SUBSCRIBES" }, + ], + }, + }, + { + name: "API Gateway routing", + description: "Single entry point: the APIGateway routes incoming requests to the appropriate Controllers; auth, rate limit and CORS are applied at the gateway level.", + tags: ["api-gateway", "routing", "edge", "bff"], + graph: { + nodes: [ + { tempId: "t_gw", type: "APIGateway", properties: { GatewayName: "MainGateway", Description: "Main entry gateway", Provider: "Kong", AuthMode: "JWT", CorsEnabled: true, Routes: [{ Path: "/users", TargetRef: "UserController", Methods: ["GET", "POST"], AuthRequired: true, RateLimit: { Requests: 100, WindowSeconds: 60 } }] } }, + { tempId: "t_ctrl", type: "Controller", properties: { ControllerName: "UserController", Description: "User API", BaseRoute: "/users", Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: true }] } }, + ], + edges: [ + { sourceTempId: "t_gw", targetTempId: "t_ctrl", edgeType: "ROUTES_TO" }, + ], + }, + }, + { + name: "Repository + custom query", + description: "Data access abstraction: the Repository manages a Table and, beyond standard CRUD, defines named custom queries (findByEmail, etc.).", + tags: ["repository", "dao", "query", "data-access"], + graph: { + nodes: [ + { tempId: "t_repo", type: "Repository", properties: { RepositoryName: "UserRepository", Description: "User data access", EntityReference: "users", IsCached: false, CustomQueries: [{ QueryName: "findByEmail", QueryType: "findOne", Parameters: [{ Name: "email", Type: "string" }], ReturnType: "User" }, { QueryName: "findActive", QueryType: "find", Parameters: [], ReturnType: "User[]" }] } }, + { tempId: "t_tbl", type: "Table", properties: { TableName: "users", Description: "Users", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }, { Name: "email", DataType: "VARCHAR", Length: 255, IsPrimaryKey: false, IsNotNull: true, IsUnique: true, AutoIncrement: false }], Indexes: [{ IndexName: "idx_email", Columns: ["email"], Type: "BTree", IsUnique: true }] } }, + ], + edges: [ + { sourceTempId: "t_repo", targetTempId: "t_tbl", edgeType: "QUERIES" }, + ], + }, + }, + { + name: "DTO validation layer", + description: "Input validation: the Controller endpoint uses a request DTO; the DTO fields are protected with structural validation rules (Email, MinLength, Min/Max). Clean data reaches the business layer.", + tags: ["dto", "validation", "input", "contract"], + graph: { + nodes: [ + { tempId: "t_ctrl", type: "Controller", properties: { ControllerName: "SignupController", Description: "Signup API", BaseRoute: "/api/v1/signup", Endpoints: [{ HttpMethod: "POST", Route: "/", RequestDTORef: "SignupDTO", ResponseDTORef: "UserDTO", RequiresAuth: false }] } }, + { tempId: "t_req", type: "DTO", properties: { Name: "SignupDTO", Description: "Signup request", Fields: [{ Name: "email", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [{ Rule: "Email" }] }, { Name: "age", DataType: "number", IsRequired: false, IsArray: false, ValidationRules: [{ Rule: "Min", Value: "18" }] }] } }, + { tempId: "t_res", type: "DTO", properties: { Name: "UserDTO", Description: "User response", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }] } }, + ], + edges: [ + { sourceTempId: "t_ctrl", targetTempId: "t_req", edgeType: "USES" }, + { sourceTempId: "t_ctrl", targetTempId: "t_res", edgeType: "RETURNS" }, + ], + }, + }, + { + name: "Exception hierarchy", + description: "Consistent error handling: custom exceptions derive from a base exception; the Service throws the appropriate exception on a business-rule violation, carrying an HTTP status + log severity.", + tags: ["exception", "error-handling", "hierarchy"], + graph: { + nodes: [ + { tempId: "t_svc", type: "Service", properties: { ServiceName: "BillingService", Description: "Billing", IsTransactionScoped: true, Methods: [{ MethodName: "charge", ReturnType: "void", IsAsync: true, Throws: ["InsufficientFundsException"] }] } }, + { tempId: "t_base", type: "Exception", properties: { ExceptionName: "DomainException", Description: "Base business error", HttpStatusCode: 400, LogSeverity: "Warning", ErrorCode: "ERR_DOMAIN" } }, + { tempId: "t_child", type: "Exception", properties: { ExceptionName: "InsufficientFundsException", Description: "Yetersiz bakiye", HttpStatusCode: 402, LogSeverity: "Warning", ErrorCode: "ERR_INSUFFICIENT_FUNDS", ParentExceptionRef: "DomainException" } }, + ], + edges: [ + { sourceTempId: "t_svc", targetTempId: "t_child", edgeType: "THROWS" }, + { sourceTempId: "t_child", targetTempId: "t_base", edgeType: "EXTENDS" }, + ], + }, + }, + { + name: "Scheduled task (Worker/cron)", + description: "Periodic background job: the Worker runs on a cron trigger, calling a Service to perform a batch job; resilient with a retry policy + timeout.", + tags: ["worker", "cron", "scheduled", "batch"], + graph: { + nodes: [ + { tempId: "t_worker", type: "Worker", properties: { WorkerName: "DailyReportWorker", Description: "Daily report generation", Schedule: "0 2 * * *", TaskToExecute: "generateDailyReport", TimeoutSeconds: 600, RetryPolicy: { MaxRetries: 3, BackoffStrategy: "exponential", DelaySeconds: 60 }, IsEnabled: true } }, + { tempId: "t_svc", type: "Service", properties: { ServiceName: "ReportService", Description: "Report generation", IsTransactionScoped: false, Methods: [{ MethodName: "generateDaily", ReturnType: "void", IsAsync: true }], Dependencies: [{ Kind: "Repository", Ref: "ReportRepository" }] } }, + ], + edges: [ + { sourceTempId: "t_worker", targetTempId: "t_svc", edgeType: "CALLS" }, + ], + }, + }, + { + name: "External service integration (circuit breaker)", + description: "Third-party API call: the Service sends REQUESTS to an ExternalService; external failures are isolated with timeout, retry and a circuit breaker.", + tags: ["external-service", "integration", "circuit-breaker", "resilience"], + graph: { + nodes: [ + { tempId: "t_svc", type: "Service", properties: { ServiceName: "CheckoutService", Description: "Payment collection", IsTransactionScoped: true, Methods: [{ MethodName: "pay", ReturnType: "PaymentResultDTO", IsAsync: true, Throws: ["PaymentGatewayException"] }], Dependencies: [{ Kind: "ExternalService", Ref: "StripeAPI" }] } }, + { tempId: "t_ext", type: "ExternalService", properties: { ServiceName: "StripeAPI", Description: "Stripe payment integration", BaseURL: "https://api.stripe.com/v1", AuthType: "Bearer", TimeoutSeconds: 30, Endpoints: [{ Name: "createCharge", Method: "POST", Path: "/charges" }], RetryPolicy: { MaxRetries: 2, DelaySeconds: 2 }, CircuitBreaker: { FailureThreshold: 5, ResetSeconds: 30 } } }, + ], + edges: [ + { sourceTempId: "t_svc", targetTempId: "t_ext", edgeType: "REQUESTS" }, + ], + }, + }, +]; diff --git a/apps/server/src/patterns/seed/seed.ts b/apps/server/src/patterns/seed/seed.ts new file mode 100644 index 0000000..42693b0 --- /dev/null +++ b/apps/server/src/patterns/seed/seed.ts @@ -0,0 +1,42 @@ +import { Neo4jService } from "../../neo4j/neo4j.service"; +import { ProjectsRepository } from "../../projects/projects.repository"; +import { PatternsRepository } from "../patterns.repository"; +import { PatternsService } from "../patterns.service"; +import { EmbeddingsService } from "../../embeddings/embeddings.service"; +import { CANONICAL_PATTERNS } from "./canonical-patterns"; +import { env } from "../../config/env"; + +/** Reconciles the canonical 'seed' patterns to the current definitions: removes + * every existing 'seed' pattern and recreates them from CANONICAL_PATTERNS, so + * renamed AND description-only changes (e.g. the old Turkish copy → English) + * always land. User-promoted patterns (source != 'seed') are never listed nor + * deleted here. Outcome-idempotent. */ +async function main(): Promise { + const neo4j = new Neo4jService({ uri: env.NEO4J_URI, user: env.NEO4J_USER, password: env.NEO4J_PASSWORD }); + await neo4j.onModuleInit(); + const repo = new PatternsRepository(neo4j); + const svc = new PatternsService(repo, new ProjectsRepository(neo4j), new EmbeddingsService()); + + // Remove every existing 'seed' pattern, then recreate from the current + // definitions — so renamed AND description-only changes always land. + let removed = 0; + for (const existing of await repo.list()) { + await repo.delete(existing.id); + removed++; + } + + let created = 0; + for (const p of CANONICAL_PATTERNS) { + await svc.create(p, "seed"); + created++; + console.log(` + ${p.name}`); + } + + await neo4j.onModuleDestroy(); + console.log(`✓ Pattern seed sync: ${removed} removed, ${created} recreated from canonical.`); +} + +main().catch((e) => { + console.error("✗ Seed failed:", e); + process.exit(1); +}); diff --git a/apps/server/src/projects/dto/create-project.dto.ts b/apps/server/src/projects/dto/create-project.dto.ts new file mode 100644 index 0000000..58e7c0d --- /dev/null +++ b/apps/server/src/projects/dto/create-project.dto.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { createZodDto } from "nestjs-zod"; +import { ProjectStatusSchema } from "../schemas/project.schema"; + +// Meaningful fields only — id/createdAt/updatedAt generated server-side. +export const CreateProjectSchema = z.object({ + name: z.string().min(1), + description: z.string().default(""), + status: ProjectStatusSchema.default("draft"), +}).strict(); + +export type CreateProjectInput = z.infer; + +export class CreateProjectDto extends createZodDto(CreateProjectSchema) {} diff --git a/apps/server/src/projects/dto/project-response.dto.ts b/apps/server/src/projects/dto/project-response.dto.ts new file mode 100644 index 0000000..ed17cbe --- /dev/null +++ b/apps/server/src/projects/dto/project-response.dto.ts @@ -0,0 +1,22 @@ +import type { Project } from "../schemas/project.schema"; +import type { Node } from "../../nodes/schemas"; +import type { Edge } from "../../edges/schemas/edge.schema"; +import type { SuccessEnvelope } from "../../common/envelope"; + +export interface ProjectWithCounts extends Project { + counts: { nodes: number; edges: number }; +} + +export interface ProjectGraph { + project: Project; + nodes: Node[]; + edges: Edge[]; + counts: { nodes: number; edges: number }; + /** Graph-level revision — +1 on every structural mutation. CLI push + * baseRevision conflict detection relies on this value. */ + graphRevision: number; +} + +export type ProjectResponse = SuccessEnvelope; +export type ProjectListResponse = SuccessEnvelope<{ projects: ProjectWithCounts[]; total: number }>; +export type ProjectGraphResponse = SuccessEnvelope; diff --git a/apps/server/src/projects/dto/report-implementation.dto.ts b/apps/server/src/projects/dto/report-implementation.dto.ts new file mode 100644 index 0000000..9ec8c27 --- /dev/null +++ b/apps/server/src/projects/dto/report-implementation.dto.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { createZodDto } from "nestjs-zod"; + +/** Implementation counters sent by CLI `status --report` / VS Code extension. + * Not a structural mutation — does not touch graphRevision. */ +const ImplementationEntrySchema = z + .object({ + nodeId: z.string().uuid(), + /** Total marked members (surgical marker count). */ + total: z.number().int().nonnegative(), + filled: z.number().int().nonnegative(), + /** Members filled by AI by signature. */ + filledAi: z.number().int().nonnegative(), + }) + .strict() + .refine((e) => e.filled <= e.total && e.filledAi <= e.filled, { + message: "filled <= total and filledAi <= filled must hold.", + }); + +export const ReportImplementationSchema = z + .object({ + entries: z.array(ImplementationEntrySchema).max(500), + }) + .strict(); + +export class ReportImplementationDto extends createZodDto(ReportImplementationSchema) {} diff --git a/apps/server/src/projects/dto/update-project.dto.ts b/apps/server/src/projects/dto/update-project.dto.ts new file mode 100644 index 0000000..176dc4c --- /dev/null +++ b/apps/server/src/projects/dto/update-project.dto.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import { createZodDto } from "nestjs-zod"; +import { ProjectStatusSchema } from "../schemas/project.schema"; + +export const UpdateProjectSchema = z.object({ + name: z.string().min(1).optional(), + description: z.string().min(1).optional(), + status: ProjectStatusSchema.optional(), +}).strict(); + +export type UpdateProjectInput = z.infer; + +export class UpdateProjectDto extends createZodDto(UpdateProjectSchema) {} diff --git a/apps/server/src/projects/projects.controller.ts b/apps/server/src/projects/projects.controller.ts new file mode 100644 index 0000000..a06f2cc --- /dev/null +++ b/apps/server/src/projects/projects.controller.ts @@ -0,0 +1,127 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Put } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiParam, ApiResponse } from "@nestjs/swagger"; +import { ProjectsService } from "./projects.service"; +import { CreateProjectDto } from "./dto/create-project.dto"; +import { UpdateProjectDto } from "./dto/update-project.dto"; +import { ReportImplementationDto } from "./dto/report-implementation.dto"; +import { CurrentAuth } from "../auth/current-auth.decorator"; +import type { AuthContext } from "../auth/auth.types"; +import { ok } from "../common/envelope"; +import type { + ProjectResponse, + ProjectListResponse, + ProjectGraphResponse, +} from "./dto/project-response.dto"; + +@ApiTags("Projects") +@Controller("projects") +export class ProjectsController { + constructor(private readonly service: ProjectsService) {} + + @Post() + @HttpCode(201) + @ApiOperation({ + summary: "Create a new project", + description: + "Opens a new architecture project (workspace). The project must exist before adding nodes/edges. If `id` is not provided the server generates a UUID. If `status` is not provided it defaults to `draft`.", + }) + @ApiResponse({ status: 201, description: "Project created. `data` returns the project + `counts: {nodes:0, edges:0}`." }) + @ApiResponse({ status: 400, description: "`ERR_SCHEMA_INVALID` — name/description missing or invalid status." }) + @ApiResponse({ status: 409, description: "`ERR_ID_CONFLICT` — the given `id` is already in use." }) + async create(@Body() body: CreateProjectDto, @CurrentAuth() auth: AuthContext): Promise { + const created = await this.service.create(body as any, auth); + return ok(created); + } + + @Get() + @ApiOperation({ + summary: "List projects", + description: "Returns all projects (newest first). Each project includes `counts` (node + edge count).", + }) + @ApiResponse({ status: 200, description: "`data: { projects: [...], total }`." }) + async list(@CurrentAuth() auth: AuthContext): Promise { + const projects = await this.service.list(auth); + return ok({ projects, total: projects.length }); + } + + @Get(":projectId") + @ApiOperation({ + summary: "Single project (+ counts)", + description: "Returns the given project with node/edge `counts`.", + }) + @ApiParam({ name: "projectId", description: "Project UUID", example: "f773f8ac-b3f0-46ac-ac79-6d9106fe4adc" }) + @ApiResponse({ status: 200, description: "Project + counts." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async getById(@Param("projectId") projectId: string, @CurrentAuth() auth: AuthContext): Promise { + const project = await this.service.getById(projectId, auth); + return ok(project); + } + + @Get(":projectId/graph") + @ApiOperation({ + summary: "The project's full graph", + description: + "Returns **all of the project's nodes + edges in a single request**: `{ project, nodes[], edges[], counts }`. Ideal for loading the frontend canvas.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "Full graph: project + nodes + edges + counts." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async getGraph(@Param("projectId") projectId: string, @CurrentAuth() auth: AuthContext): Promise { + const graph = await this.service.getGraph(projectId, auth); + return ok(graph); + } + + @Put(":projectId/implementation") + @HttpCode(200) + @ApiOperation({ + summary: "Report implementation status (surgical fill counters)", + description: + "Written by the Solarch CLI (`solarch status --report`) and the VS Code extension. Stores per-node " + + "implementation counters (`implTotal`, `implFilled`, `implAi`) derived from `@solarch:surgical` markers " + + "in the codebase. NOT a structural mutation: `graphRevision` is not bumped and node `version` does not " + + "change. Unknown nodeIds are silently skipped. The canvas uses these counters for completion badges.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "`data: { updated }` — number of nodes that received counters." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async reportImplementation( + @Param("projectId") projectId: string, + @Body() body: ReportImplementationDto, + @CurrentAuth() auth: AuthContext, + ): Promise>> { + const result = await this.service.reportImplementation(projectId, body.entries, auth); + return ok(result); + } + + @Patch(":projectId") + @ApiOperation({ + summary: "Update the project", + description: "Only `name`, `description` and `status` can be updated (partial). `id` and timestamps do not change.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "Updated project + counts." }) + @ApiResponse({ status: 400, description: "`ERR_SCHEMA_INVALID`." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async update( + @Param("projectId") projectId: string, + @Body() body: UpdateProjectDto, + @CurrentAuth() auth: AuthContext, + ): Promise { + const updated = await this.service.update(projectId, body as any, auth); + return ok(updated); + } + + @Delete(":projectId") + @HttpCode(204) + @ApiOperation({ + summary: "Delete the project (cascade)", + description: + "Permanently deletes the project **and all its nodes + edges** (cascade). Cannot be undone.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 204, description: "Deleted (no body)." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async delete(@Param("projectId") projectId: string, @CurrentAuth() auth: AuthContext): Promise { + await this.service.delete(projectId, auth); + } +} diff --git a/apps/server/src/projects/projects.module.ts b/apps/server/src/projects/projects.module.ts new file mode 100644 index 0000000..9489cd9 --- /dev/null +++ b/apps/server/src/projects/projects.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { ProjectsController } from "./projects.controller"; +import { ProjectsService } from "./projects.service"; +import { ProjectsRepository } from "./projects.repository"; +import { TabsModule } from "../tabs/tabs.module"; + +// Nodes/Edges/Graph import this. TabsModule depends only on Neo4jModule, +// so Projects → Tabs is one-way (no cycle). +@Module({ + imports: [TabsModule], + controllers: [ProjectsController], + providers: [ProjectsService, ProjectsRepository], + exports: [ProjectsRepository], +}) +export class ProjectsModule {} diff --git a/apps/server/src/projects/projects.repository.ts b/apps/server/src/projects/projects.repository.ts new file mode 100644 index 0000000..c9bb3e3 --- /dev/null +++ b/apps/server/src/projects/projects.repository.ts @@ -0,0 +1,332 @@ +import { Injectable } from "@nestjs/common"; +import { Neo4jService } from "../neo4j/neo4j.service"; +import type { ProjectStatus } from "./schemas/project.schema"; +import type { Node, NodeKind } from "../nodes/schemas"; +import { redactNodeSecrets } from "../nodes/secret-redaction"; +import type { Edge, EdgeKind } from "../edges/schemas/edge.schema"; + +export interface StoredProject { + id: string; + name: string; + description: string; + status: ProjectStatus; + ownerId: string; + orgId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ProjectUpdate { + name?: string; + description?: string; + status?: ProjectStatus; + updatedAt: string; +} + +@Injectable() +export class ProjectsRepository { + constructor(private readonly neo4j: Neo4jService) {} + + async create(p: StoredProject): Promise { + await this.neo4j.run( + `CREATE (p:Project { + id: $id, name: $name, description: $description, status: $status, + ownerId: $ownerId, orgId: $orgId, + createdAt: datetime($createdAt), updatedAt: datetime($updatedAt) + })`, + { + id: p.id, + name: p.name, + description: p.description, + status: p.status, + ownerId: p.ownerId, + orgId: p.orgId, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + }, + ); + } + + async getById(id: string): Promise { + const result = await this.neo4j.run(`MATCH (p:Project {id: $id}) RETURN p`, { id }); + if (result.records.length === 0) return null; + return toStoredProject(result.records[0].get("p")); + } + + /** Projects in caller scope: org's projects when org active, otherwise + * personal (ownerId match and not org-owned) projects. */ + async list(scope: { userId: string; orgId: string | null }): Promise { + const cypher = scope.orgId + ? `MATCH (p:Project) WHERE p.orgId = $orgId RETURN p ORDER BY p.createdAt DESC` + : `MATCH (p:Project) WHERE p.ownerId = $userId AND p.orgId IS NULL RETURN p ORDER BY p.createdAt DESC`; + const result = await this.neo4j.run(cypher, { orgId: scope.orgId, userId: scope.userId }); + return result.records.map((r) => toStoredProject(r.get("p"))); + } + + async update(id: string, update: ProjectUpdate): Promise { + const partial: Record = { updatedAt: update.updatedAt }; + if (update.name !== undefined) partial.name = update.name; + if (update.description !== undefined) partial.description = update.description; + if (update.status !== undefined) partial.status = update.status; + + const result = await this.neo4j.run( + `MATCH (p:Project {id: $id}) + SET p += $partial, p.updatedAt = datetime($updatedAt) + RETURN p`, + { id, partial, updatedAt: update.updatedAt }, + ); + if (result.records.length === 0) return null; + return toStoredProject(result.records[0].get("p")); + } + + /** Cascade delete: project + all nodes + edges (DETACH). */ + async delete(id: string): Promise { + const result = await this.neo4j.run( + `MATCH (p:Project {id: $id}) + WITH p + OPTIONAL MATCH (n:Node {projectId: $id}) + DETACH DELETE n + WITH p + DETACH DELETE p + RETURN 1 AS deleted`, + { id }, + ); + return result.records.length > 0; + } + + async exists(id: string): Promise { + const result = await this.neo4j.run( + `MATCH (p:Project {id: $id}) RETURN p LIMIT 1`, + { id }, + ); + return result.records.length > 0; + } + + /** Reassign ownership (e.g. admin transfer). */ + async reassignOwner(id: string, ownerId: string, orgId: string | null): Promise { + await this.neo4j.run( + `MATCH (p:Project {id: $id}) + SET p.ownerId = $ownerId, p.orgId = $orgId, p.updatedAt = datetime($now)`, + { id, ownerId, orgId, now: new Date().toISOString() }, + ); + } + + /** Read Constructor version stamped on project. + * No project -> undefined; project exists but never codegen'd -> null + * ("not yet generated"); otherwise stamped integer. */ + async getCodegenVersion(id: string): Promise { + const result = await this.neo4j.run( + `MATCH (p:Project {id: $id}) RETURN p.codegenVersion AS v`, + { id }, + ); + if (result.records.length === 0) return undefined; // no project + const v = result.records[0].get("v"); + return v == null ? null : Number(v); // Neo4j Integer -> number + } + + /** After successful codegen, stamp Constructor version + graphRevision AT GENERATION TIME + * on project node. Second is for drift ("did diagram change after generation"): + * status reports drift when codegenGraphRevision < graphRevision. + * Stored as int via toInteger() (not float). */ + async setCodegenVersion(id: string, version: number): Promise { + await this.neo4j.run( + `MATCH (p:Project {id: $id}) + SET p.codegenVersion = toInteger($version), + p.codegenGraphRevision = coalesce(p.graphRevision, 0)`, + { id, version }, + ); + } + + /** graphRevision stamped at generation time — for drift calculation. null if never generated. */ + async getCodegenGraphRevision(id: string): Promise { + const result = await this.neo4j.run( + `MATCH (p:Project {id: $id}) RETURN p.codegenGraphRevision AS rev`, + { id }, + ); + if (result.records.length === 0) return null; + const v = result.records[0].get("rev"); + return v == null ? null : Number(v); + } + + /** Persisted Simple-View model (the AI-enriched diagram) so it survives restarts and is reused + * until the graph changes. Keyed by the deterministic baseline hash; only AI results are stored + * (the deterministic baseline is recomputed instantly, nothing to persist). Returns null if none + * is stored or the stored JSON is unreadable. NOTE: stored as a property on the Project node; + * toStoredProject picks named fields, so this never bloats the project DTO. */ + async getSimpleSketchModel(id: string): Promise<{ key: string; model: T } | null> { + const result = await this.neo4j.run( + `MATCH (p:Project {id: $id}) RETURN p.simpleSketchKey AS key, p.simpleSketchJson AS json`, + { id }, + ); + if (result.records.length === 0) return null; + const key = result.records[0].get("key"); + const json = result.records[0].get("json"); + if (key == null || json == null) return null; + try { return { key: String(key), model: JSON.parse(String(json)) as T }; } + catch { return null; } // corrupt/legacy payload -> treat as no cache (regenerate) + } + + /** Persist the AI-enriched Simple-View model for a project (overwrites any previous one). */ + async setSimpleSketchModel(id: string, key: string, model: unknown): Promise { + await this.neo4j.run( + `MATCH (p:Project {id: $id}) + SET p.simpleSketchKey = $key, p.simpleSketchJson = $json`, + { id, key, json: JSON.stringify(model) }, + ); + } + + /** Persisted AI-enriched OpenAPI doc (the "AI Documentize" result) so it survives restarts and is + * reused until the graph changes. Keyed by the deterministic baseline hash; only AI results are + * stored (the deterministic baseline is recomputed instantly, nothing to persist). Returns null if + * none is stored or the stored JSON is unreadable. Mirrors getSimpleSketchModel — stored as named + * fields on the Project node, so toStoredProject never bloats the project DTO. */ + async getOpenApiDoc(id: string): Promise<{ key: string; doc: T } | null> { + const result = await this.neo4j.run( + `MATCH (p:Project {id: $id}) RETURN p.openApiKey AS key, p.openApiJson AS json`, + { id }, + ); + if (result.records.length === 0) return null; + const key = result.records[0].get("key"); + const json = result.records[0].get("json"); + if (key == null || json == null) return null; + try { return { key: String(key), doc: JSON.parse(String(json)) as T }; } + catch { return null; } // corrupt/legacy payload -> treat as no cache (regenerate) + } + + /** Persist the AI-enriched OpenAPI doc for a project (overwrites any previous one). */ + async setOpenApiDoc(id: string, key: string, doc: unknown): Promise { + await this.neo4j.run( + `MATCH (p:Project {id: $id}) + SET p.openApiKey = $key, p.openApiJson = $json`, + { id, key, json: JSON.stringify(doc) }, + ); + } + + /** Increments graph revision counter by +1 and returns new value. Called on structural + * mutations (node/edge create-update-delete, graph/apply); position/tab layout save + * does NOT call (avoids drift, unnecessary conflicts). */ + async bumpRevision(id: string): Promise { + const result = await this.neo4j.run( + `MATCH (p:Project {id: $id}) + SET p.graphRevision = coalesce(p.graphRevision, 0) + 1 + RETURN p.graphRevision AS rev`, + { id }, + ); + if (result.records.length === 0) return 0; + return Number(result.records[0].get("rev")); + } + + async getGraphRevision(id: string): Promise { + const result = await this.neo4j.run( + `MATCH (p:Project {id: $id}) RETURN coalesce(p.graphRevision, 0) AS rev`, + { id }, + ); + if (result.records.length === 0) return 0; + return Number(result.records[0].get("rev")); + } + + /** Write implementation counters to nodes (CLI/extension report). + * NOT structural mutation: graphRevision not bumped, version not incremented. + * Unknown nodeIds silently skipped (MATCH misses). */ + async setImplementation( + projectId: string, + entries: { nodeId: string; total: number; filled: number; filledAi: number }[], + ): Promise { + const result = await this.neo4j.run( + `UNWIND $entries AS e + MATCH (n:Node {projectId: $projectId, id: e.nodeId}) + SET n.implTotal = e.total, n.implFilled = e.filled, n.implAi = e.filledAi, + n.implAt = datetime() + RETURN count(n) AS updated`, + { projectId, entries }, + ); + if (result.records.length === 0) return 0; + return Number(result.records[0].get("updated")); + } + + async counts(id: string): Promise<{ nodes: number; edges: number }> { + const result = await this.neo4j.run( + `OPTIONAL MATCH (n:Node {projectId: $id}) + WITH count(n) AS nodeCount + OPTIONAL MATCH ()-[r]->() WHERE r.projectId = $id + RETURN nodeCount, count(r) AS edgeCount`, + { id }, + ); + const rec = result.records[0]; + return { + nodes: Number(rec.get("nodeCount")), + edges: Number(rec.get("edgeCount")), + }; + } + + /** Returns all project nodes + edges in domain format. */ + async getGraph(id: string): Promise<{ nodes: Node[]; edges: Edge[] }> { + const nodesResult = await this.neo4j.run( + `MATCH (n:Node {projectId: $id}) RETURN n, labels(n) AS labels`, + { id }, + ); + const nodes = nodesResult.records.map((r) => nodeFromRecord(r.get("n"), r.get("labels"))); + + const edgesResult = await this.neo4j.run( + `MATCH (s:Node)-[r]->(t:Node) + WHERE r.projectId = $id + RETURN r, type(r) AS kind, s.id AS sourceId, t.id AS targetId`, + { id }, + ); + const edges = edgesResult.records.map((r) => + edgeFromRecord(r.get("r"), r.get("kind"), r.get("sourceId"), r.get("targetId")), + ); + + return { nodes, edges }; + } +} + +function toStoredProject(p: any): StoredProject { + return { + id: p.properties.id, + name: p.properties.name, + description: p.properties.description, + status: p.properties.status, + ownerId: p.properties.ownerId ?? "", + orgId: p.properties.orgId ?? null, + createdAt: new Date(p.properties.createdAt).toISOString(), + updatedAt: new Date(p.properties.updatedAt).toISOString(), + }; +} + +function nodeFromRecord(n: any, labels: string[]): Node { + const props = n.properties; + const kind = labels.find((l: string) => l !== "Node") as NodeKind; + return { + id: props.id, + type: kind, + projectId: props.projectId, + position: { x: Number(props.positionX), y: Number(props.positionY) }, + homeTabId: props.homeTabId, + createdAt: new Date(props.createdAt).toISOString(), + updatedAt: new Date(props.updatedAt).toISOString(), + version: Number(props.version ?? 1), + // Implementation counters — fields omitted when never reported. + ...(props.implTotal != null + ? { + implTotal: Number(props.implTotal), + implFilled: Number(props.implFilled ?? 0), + implAi: Number(props.implAi ?? 0), + } + : {}), + properties: redactNodeSecrets(kind, JSON.parse(props.properties)), + } as Node; +} + +function edgeFromRecord(r: any, kind: string, sourceId: string, targetId: string): Edge { + return { + id: r.properties.id, + projectId: r.properties.projectId, + sourceNodeId: sourceId, + targetNodeId: targetId, + kind: kind as EdgeKind, + createdAt: new Date(r.properties.createdAt).toISOString(), + updatedAt: new Date(r.properties.updatedAt).toISOString(), + properties: JSON.parse(r.properties.properties), + }; +} diff --git a/apps/server/src/projects/projects.service.spec.ts b/apps/server/src/projects/projects.service.spec.ts new file mode 100644 index 0000000..c76c343 --- /dev/null +++ b/apps/server/src/projects/projects.service.spec.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ForbiddenException, NotFoundException } from "@nestjs/common"; +import { ProjectsService } from "./projects.service"; +import type { StoredProject } from "./projects.repository"; + +function makeRepo(initial: StoredProject[] = []) { + const store = new Map(initial.map((p) => [p.id, p])); + return { + store, + create: vi.fn(async (p: StoredProject) => { store.set(p.id, p); }), + getById: vi.fn(async (id: string) => store.get(id) ?? null), + // Scope filters like the real repo: personal projects match ownerId when no org. + list: vi.fn(async (scope?: { userId?: string; orgId?: string | null }) => + [...store.values()].filter((p) => + scope?.orgId ? p.orgId === scope.orgId : scope?.userId ? p.ownerId === scope.userId && p.orgId == null : true, + ), + ), + update: vi.fn(async (id: string, upd: any) => { + const ex = store.get(id); + if (!ex) return null; + const next = { ...ex, ...upd }; + store.set(id, next); + return next; + }), + delete: vi.fn(async (id: string) => store.delete(id)), + exists: vi.fn(async (id: string) => store.has(id)), + counts: vi.fn(async () => ({ nodes: 2, edges: 1 })), + getGraph: vi.fn(async () => ({ nodes: [], edges: [] })), + getGraphRevision: vi.fn(async () => 0), + setImplementation: vi.fn(async (_id: string, entries: unknown[]) => entries.length), + reassignOwner: vi.fn(async (id: string, ownerId: string, orgId: string | null) => { + const ex = store.get(id); + if (ex) store.set(id, { ...ex, ownerId, orgId }); + }), + }; +} + +function ownProject(id = "ip-1"): StoredProject { + return { + id, + name: "Project", + description: "", + status: "draft", + ownerId: "user_1", + orgId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; +} + +describe("ProjectsService", () => { + let repo: ReturnType; + let service: ProjectsService; + + const tabs = { ensureDefault: vi.fn(async () => ({ id: "t" })) }; + const auth = { userId: "user_1", orgId: null, orgRole: null }; + + beforeEach(() => { + repo = makeRepo(); + service = new ProjectsService(repo as any, tabs as any); + }); + + it("create generates id + zero counts + stamps ownership", async () => { + const p = await service.create({ name: "X", description: "d", status: "draft" } as any, auth); + expect(p.id).toMatch(/^[0-9a-f-]{36}$/); + expect(p.counts).toEqual({ nodes: 0, edges: 0 }); + expect(p.ownerId).toBe("user_1"); + expect(p.orgId).toBeNull(); + }); + + it("create works with name only (description/status defaults)", async () => { + const p = await service.create({ name: "Restaurant", description: "", status: "draft" } as any, auth); + expect(p.name).toBe("Restaurant"); + expect(p.id).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("getById missing → ERR_PROJECT_NOT_FOUND", async () => { + await expect(service.getById("00000000-0000-0000-0000-000000000000", auth)) + .rejects.toBeInstanceOf(NotFoundException); + }); + + it("getById found → returns with counts", async () => { + const created = await service.create({ name: "X", description: "d", status: "active" } as any, auth); + const got = await service.getById(created.id, auth); + expect(got.counts).toEqual({ nodes: 2, edges: 1 }); + }); + + it("other user cannot access → ERR_PROJECT_FORBIDDEN", async () => { + const created = await service.create({ name: "X", description: "d", status: "draft" } as any, auth); + const other = { userId: "user_2", orgId: null, orgRole: null }; + await expect(service.getById(created.id, other)).rejects.toBeInstanceOf(ForbiddenException); + }); + + it("update missing → NotFound", async () => { + await expect(service.update("00000000-0000-0000-0000-000000000000", { name: "Z" }, auth)) + .rejects.toBeInstanceOf(NotFoundException); + }); + + it("delete missing → NotFound", async () => { + await expect(service.delete("00000000-0000-0000-0000-000000000000", auth)) + .rejects.toBeInstanceOf(NotFoundException); + }); + + it("delete found → resolves", async () => { + const created = await service.create({ name: "X", description: "d", status: "draft" } as any, auth); + await expect(service.delete(created.id, auth)).resolves.toBeUndefined(); + }); + + it("getGraph returns project + nodes + edges + counts", async () => { + const created = await service.create({ name: "X", description: "d", status: "draft" } as any, auth); + const g = await service.getGraph(created.id, auth); + expect(g.project.id).toBe(created.id); + expect(g.nodes).toEqual([]); + expect(g.edges).toEqual([]); + expect(g.counts).toEqual({ nodes: 0, edges: 0 }); + }); + + describe("reportImplementation", () => { + const entries = [{ nodeId: "11111111-1111-4111-8111-111111111111", total: 3, filled: 2, filledAi: 1 }]; + + it("writes counts to repo and returns updated", async () => { + repo = makeRepo([ownProject()]); + service = new ProjectsService(repo as any, tabs as any); + const result = await service.reportImplementation("ip-1", entries, auth); + expect(result).toEqual({ updated: 1 }); + expect(repo.setImplementation).toHaveBeenCalledWith("ip-1", entries); + }); + + it("empty report is no-op (does not hit repo)", async () => { + repo = makeRepo([ownProject()]); + service = new ProjectsService(repo as any, tabs as any); + const result = await service.reportImplementation("ip-1", [], auth); + expect(result).toEqual({ updated: 0 }); + expect(repo.setImplementation).not.toHaveBeenCalled(); + }); + + it("cannot report to another user's project → 403", async () => { + repo = makeRepo([{ ...ownProject(), ownerId: "user_2" }]); + service = new ProjectsService(repo as any, tabs as any); + await expect(service.reportImplementation("ip-1", entries, auth)) + .rejects.toBeInstanceOf(ForbiddenException); + }); + + it("missing project → 404", async () => { + await expect(service.reportImplementation("ghost", entries, auth)) + .rejects.toBeInstanceOf(NotFoundException); + }); + }); +}); diff --git a/apps/server/src/projects/projects.service.ts b/apps/server/src/projects/projects.service.ts new file mode 100644 index 0000000..d799cbb --- /dev/null +++ b/apps/server/src/projects/projects.service.ts @@ -0,0 +1,114 @@ +import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; +import { randomUUID } from "node:crypto"; +import { ProjectsRepository, type StoredProject } from "./projects.repository"; +import { TabsService } from "../tabs/tabs.service"; +import { hasProjectAccess, ownershipFor, projectScope } from "../auth/access"; +import type { AuthContext } from "../auth/auth.types"; +import type { CreateProjectInput } from "./dto/create-project.dto"; +import type { UpdateProjectInput } from "./dto/update-project.dto"; +import type { + ProjectWithCounts, + ProjectGraph, +} from "./dto/project-response.dto"; + +@Injectable() +export class ProjectsService { + constructor( + private readonly repo: ProjectsRepository, + private readonly tabs: TabsService, + ) {} + + async create(input: CreateProjectInput, auth: AuthContext): Promise { + const now = new Date().toISOString(); + const stored: StoredProject = { + id: randomUUID(), + name: input.name, + description: input.description, + status: input.status, + ...ownershipFor(auth), + createdAt: now, + updatedAt: now, + }; + await this.repo.create(stored); + // Auto-create default "Main Architecture" tab for each project. + await this.tabs.ensureDefault(stored.id); + return { ...stored, counts: { nodes: 0, edges: 0 } }; + } + + async getById(id: string, auth: AuthContext): Promise { + const project = await this.assertAccess(id, auth); + const counts = await this.repo.counts(id); + return { ...project, counts }; + } + + async list(auth: AuthContext): Promise { + const projects = await this.repo.list(projectScope(auth)); + const out: ProjectWithCounts[] = []; + for (const p of projects) { + const counts = await this.repo.counts(p.id); + out.push({ ...p, counts }); + } + return out; + } + + async update(id: string, input: UpdateProjectInput, auth: AuthContext): Promise { + await this.assertAccess(id, auth); + const updatedAt = new Date().toISOString(); + const updated = await this.repo.update(id, { ...input, updatedAt }); + if (!updated) throw this.notFound(id); + const counts = await this.repo.counts(id); + return { ...updated, counts }; + } + + async delete(id: string, auth: AuthContext): Promise { + await this.assertAccess(id, auth); + const deleted = await this.repo.delete(id); + if (!deleted) throw this.notFound(id); + } + + /** Implementation report — writes counters onto nodes (Phase B: canvas badges). */ + async reportImplementation( + id: string, + entries: { nodeId: string; total: number; filled: number; filledAi: number }[], + auth: AuthContext, + ): Promise<{ updated: number }> { + await this.assertAccess(id, auth); + if (entries.length === 0) return { updated: 0 }; + const updated = await this.repo.setImplementation(id, entries); + return { updated }; + } + + async getGraph(id: string, auth: AuthContext): Promise { + const project = await this.assertAccess(id, auth); + const { nodes, edges } = await this.repo.getGraph(id); + const graphRevision = await this.repo.getGraphRevision(id); + return { + project, + nodes, + edges, + counts: { nodes: nodes.length, edges: edges.length }, + graphRevision, + }; + } + + /** Loads project; throws if missing OR caller lacks access. Returns 403 when + * access is denied (404 only when the project truly does not exist). */ + private async assertAccess(id: string, auth: AuthContext): Promise { + const project = await this.repo.getById(id); + if (!project) throw this.notFound(id); + if (!hasProjectAccess(project, auth)) { + throw new ForbiddenException({ + code: "ERR_PROJECT_FORBIDDEN", + message: "You do not have access to this project.", + }); + } + return project; + } + + private notFound(id: string): NotFoundException { + return new NotFoundException({ + code: "ERR_PROJECT_NOT_FOUND", + message: `Project '${id}' not found.`, + }); + } +} diff --git a/apps/server/src/projects/schemas/project.schema.spec.ts b/apps/server/src/projects/schemas/project.schema.spec.ts new file mode 100644 index 0000000..70a8786 --- /dev/null +++ b/apps/server/src/projects/schemas/project.schema.spec.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { ProjectSchema, ProjectStatusSchema } from "./project.schema"; + +const valid = { + id: "550e8400-e29b-41d4-a716-446655440000", + name: "E-Commerce Microservices", + description: "Main architecture", + status: "draft" as const, + ownerId: "user_123", + orgId: null, + createdAt: "2026-05-22T10:00:00.000Z", + updatedAt: "2026-05-22T10:00:00.000Z", +}; + +describe("ProjectSchema", () => { + it("parses valid project", () => { + expect(ProjectSchema.parse(valid).name).toBe("E-Commerce Microservices"); + }); + + it("throws when name is empty", () => { + expect(() => ProjectSchema.parse({ ...valid, name: "" })).toThrow(); + }); + + it("description is required", () => { + const { description, ...rest } = valid; + expect(() => ProjectSchema.parse(rest)).toThrow(); + }); + + it("rejects unknown status", () => { + expect(() => ProjectSchema.parse({ ...valid, status: "live" })).toThrow(); + }); + + it("ProjectStatusSchema has 3 values", () => { + expect(ProjectStatusSchema.options).toEqual(["draft", "active", "archived"]); + }); +}); diff --git a/apps/server/src/projects/schemas/project.schema.ts b/apps/server/src/projects/schemas/project.schema.ts new file mode 100644 index 0000000..050d504 --- /dev/null +++ b/apps/server/src/projects/schemas/project.schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const ProjectStatusSchema = z.enum(["draft", "active", "archived"]); +export type ProjectStatus = z.infer; + +export const ProjectSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1), + description: z.string(), // may be empty + status: ProjectStatusSchema, + // Ownership / multi-tenancy (ownerId from LocalAuthGuard or API key) + ownerId: z.string(), + orgId: z.string().nullable(), // reserved for workspace scoping; null in OSS edition + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}).strict(); + +export type Project = z.infer; diff --git a/apps/server/src/rules/checkers/circular-dependency.checker.ts b/apps/server/src/rules/checkers/circular-dependency.checker.ts new file mode 100644 index 0000000..ef21b60 --- /dev/null +++ b/apps/server/src/rules/checkers/circular-dependency.checker.ts @@ -0,0 +1,43 @@ +import { Injectable } from "@nestjs/common"; +import { Neo4jService } from "../../neo4j/neo4j.service"; +import type { EvaluationContext, EvaluationResult } from "../types"; + +/** ERR_COND_001 — is there a reverse path on Service → CALLS → Service? */ +@Injectable() +export class CircularDependencyChecker { + constructor(private readonly neo4j: Neo4jService) {} + + async check(ctx: EvaluationContext): Promise { + if (ctx.edgeKind !== "CALLS") return { allowed: true }; + if (ctx.sourceNode.type !== "Service" || ctx.targetNode.type !== "Service") return { allowed: true }; + + // If (target)-[:CALLS*1..10]->(source) path exists, new (source → target) creates a cycle. + const result = await this.neo4j.run( + `MATCH path = (t:Node {id: $targetId, projectId: $projectId})-[:CALLS*1..10]->(s:Node {id: $sourceId, projectId: $projectId}) + RETURN [n IN nodes(path) | coalesce(apoc.convert.fromJsonMap(n.properties).ServiceName, n.id)] AS chain + LIMIT 1`, + { + sourceId: ctx.sourceNode.id, + targetId: ctx.targetNode.id, + projectId: ctx.projectId, + }, + ); + + if (result.records.length === 0) return { allowed: true }; + + const chain = result.records[0].get("chain") as string[]; + const srcName = (ctx.sourceNode.properties as any).ServiceName ?? ctx.sourceNode.id; + const tgtName = (ctx.targetNode.properties as any).ServiceName ?? ctx.targetNode.id; + const culprit = [srcName, ...chain].join(" → "); + + return { + allowed: false, + severity: "error", + code: "ERR_COND_001", + ruleViolated: "CIRCULAR_DEPENDENCY", + message: `Circular dependency detected: ${culprit}. '${tgtName}' already calls '${srcName}' directly or transitively — this connection leads to an infinite loop (Stack Overflow).`, + suggestion: + "To break the cycle, perform asynchronous decoupling with an Orchestrator (Saga pattern) or a MessageQueue (event-driven).", + }; + } +} diff --git a/apps/server/src/rules/checkers/empty-schema.checker.ts b/apps/server/src/rules/checkers/empty-schema.checker.ts new file mode 100644 index 0000000..502331e --- /dev/null +++ b/apps/server/src/rules/checkers/empty-schema.checker.ts @@ -0,0 +1,24 @@ +import { Injectable } from "@nestjs/common"; +import type { EvaluationContext, EvaluationResult } from "../types"; + +/** WARN_COND_001 — warn when target Table has empty schema on Repository → QUERIES → Table. */ +@Injectable() +export class EmptySchemaChecker { + check(ctx: EvaluationContext): EvaluationResult { + if (ctx.edgeKind !== "QUERIES") return { allowed: true }; + if (ctx.targetNode.type !== "Table") return { allowed: true }; + + const columns = ((ctx.targetNode.properties as any).Columns ?? []) as unknown[]; + if (columns.length > 0) return { allowed: true }; + + const tableName = (ctx.targetNode.properties as any).TableName ?? ctx.targetNode.id; + return { + allowed: true, // warning — edge still created + severity: "warning", + code: "WARN_COND_001", + ruleViolated: "EMPTY_SCHEMA", + message: `You are trying to query an empty table: '${tableName}'. The table's Columns are empty — the query may be meaningless.`, + suggestion: "Add at least one Column to the Table node first.", + }; + } +} diff --git a/apps/server/src/rules/checkers/type-mismatch.checker.ts b/apps/server/src/rules/checkers/type-mismatch.checker.ts new file mode 100644 index 0000000..dea5dfd --- /dev/null +++ b/apps/server/src/rules/checkers/type-mismatch.checker.ts @@ -0,0 +1,49 @@ +import { Injectable } from "@nestjs/common"; +import type { EvaluationContext, EvaluationResult } from "../types"; + +/** ERR_COND_002 — Controller → CALLS → Service RequestDTORef ↔ Parameter alignment. */ +@Injectable() +export class TypeMismatchChecker { + check(ctx: EvaluationContext): EvaluationResult { + if (ctx.edgeKind !== "CALLS") return { allowed: true }; + if (ctx.sourceNode.type !== "Controller" || ctx.targetNode.type !== "Service") return { allowed: true }; + + const ctrlEndpoints = ((ctx.sourceNode.properties as any).Endpoints ?? []) as Array<{ + RequestDTORef?: string; + }>; + const srvMethods = ((ctx.targetNode.properties as any).Methods ?? []) as Array<{ + Parameters?: Array<{ Type?: string; DtoRef?: string }>; + }>; + + const ctrlDTOs = new Set(); + for (const ep of ctrlEndpoints) { + if (ep.RequestDTORef) ctrlDTOs.add(ep.RequestDTORef); + } + // Skip when Controller specifies no RequestDTORef (e.g. GET-only endpoints) + if (ctrlDTOs.size === 0) return { allowed: true }; + + const srvInputTypes = new Set(); + for (const m of srvMethods) { + for (const p of m.Parameters ?? []) { + // DtoRef preferred (DTO reference), else raw Type. + if (p.DtoRef) srvInputTypes.add(p.DtoRef); + if (p.Type) srvInputTypes.add(p.Type); + } + } + // Skip when Service expects no parameters + if (srvInputTypes.size === 0) return { allowed: true }; + + const matches = [...ctrlDTOs].filter((dto) => srvInputTypes.has(dto)); + if (matches.length > 0) return { allowed: true }; + + return { + allowed: false, + severity: "error", + code: "ERR_COND_002", + ruleViolated: "TYPE_MISMATCH", + message: `Parameter type mismatch: the Controller RequestDTORefs [${[...ctrlDTOs].join(", ")}] do not match any of the Service parameter types [${[...srvInputTypes].join(", ")}].`, + suggestion: + "Make the Controller endpoint's RequestDTORef or the Service method's parameter type (Type/DtoRef) compatible; or add a mapping/adapter layer.", + }; + } +} diff --git a/apps/server/src/rules/registry/blacklist.ts b/apps/server/src/rules/registry/blacklist.ts new file mode 100644 index 0000000..77f8420 --- /dev/null +++ b/apps/server/src/rules/registry/blacklist.ts @@ -0,0 +1,76 @@ +import { ACTIVE_KINDS, CLIENT_KINDS, PASSIVE_KINDS, type DenyRule } from "../types"; + +/* Plans/Rules Matrix — Section 2 (Blacklist), 7 ERR codes. + * These rules apply first even when whitelist matches. */ +export const BLACKLIST: DenyRule[] = [ + { + code: "ERR_001", + source: CLIENT_KINDS, + edge: "*", + target: ["Table", "View"], + message: + "Critical Security Violation: the client layer must never access the database directly. It must go through an API or Controller.", + suggestion: + "Connect the Frontend to an APIGateway or Controller via REQUESTS; then build the Controller → Service → Repository → Table chain.", + }, + { + code: "ERR_002", + source: "Controller", + edge: ["QUERIES", "WRITES"], + target: ["Table", "View"], + message: + "Architecture Violation: Controllers are not Data Access components. They cannot go directly to a Table. There must be a Service or Repository in between.", + suggestion: + "Connect the Controller to a Service via CALLS; connect the Service to a Repository via CALLS; have the Repository QUERIES/WRITES the Table.", + }, + { + code: "ERR_003", + source: PASSIVE_KINDS, + edge: "*", + target: ACTIVE_KINDS, + message: + "Logic Error: data objects (Table/View/Enum/DTO) are passive. They cannot start operations, call services, or make requests.", + suggestion: + "Data types are always the target, never the source. Reverse the direction.", + }, + { + code: "ERR_004", + source: "DTO", + edge: ["HAS", "USES"], + target: "Model", + message: + "Layer Violation: DTOs must not leak the business model (Entity). You are exposing the database schema to the client.", + suggestion: + "A DTO may only reference another DTO. Wrap the Model in a separate DTO or add a mapping layer.", + }, + { + code: "ERR_005", + source: ["Service", "Repository"], + edge: "REQUESTS", + target: CLIENT_KINDS, + message: + "Flow Error: the server (Backend) cannot send an HTTP request to the client. Communication must use a Socket (Push) or Client Polling.", + suggestion: + "Use push notifications via a MessageQueue, or the client's periodic GET.", + }, + { + code: "ERR_006", + source: "APIGateway", + edge: ["CALLS", "ROUTES_TO"], + target: ["Repository", "Table"], + message: + "Security Violation: the Gateway cannot route directly to a database or repository. Business logic (Service/Controller) must sit in between.", + suggestion: + "APIGateway → ROUTES_TO → Controller → CALLS → Service → CALLS → Repository → Table.", + }, + { + code: "ERR_007", + source: "EventHandler", + edge: "RETURNS", + target: ["Controller", "DTO"], + message: + "Flow Error: asynchronous event listeners (Event Handlers) cannot return a value (Fire-and-Forget). They must PUBLISH results to another Queue.", + suggestion: + "Within the EventHandler, PUBLISH results to a new MessageQueue or WRITES state to the DB.", + }, +]; diff --git a/apps/server/src/rules/registry/conditional.ts b/apps/server/src/rules/registry/conditional.ts new file mode 100644 index 0000000..01620cc --- /dev/null +++ b/apps/server/src/rules/registry/conditional.ts @@ -0,0 +1,30 @@ +import type { ConditionalRuleDescriptor } from "../types"; + +/* Plans/Rules Matrix — Section 3 (Conditional). + * Deep (graph + cross-field) checks that run even when whitelist matches. */ +export const CONDITIONAL_RULES: ConditionalRuleDescriptor[] = [ + { + code: "ERR_COND_001", + type: "CIRCULAR_DEPENDENCY", + severity: "error", + description: + "When Service_A → CALLS → Service_B exists, a Service_B → CALLS → Service_A connection cannot be created (infinite loop).", + appliesWhen: "Graph traversal is performed when both source and target are of type Service and the edge is CALLS.", + }, + { + code: "ERR_COND_002", + type: "TYPE_MISMATCH", + severity: "error", + description: + "When, in Controller → CALLS → Service, the Controller's RequestDTORef does not match the Service's parameter types.", + appliesWhen: "Source Controller, target Service, edge CALLS. Controller.Endpoints[].RequestDTORef and Service.Methods[].Parameters[].Type/DtoRef are compared.", + }, + { + code: "WARN_COND_001", + type: "EMPTY_SCHEMA", + severity: "warning", + description: + "In Repository → QUERIES → Table, a warning is raised if the target Table's Columns are empty (the edge is still created).", + appliesWhen: "Edge kind QUERIES, target Table, properties.Columns array is empty.", + }, +]; diff --git a/apps/server/src/rules/registry/whitelist.ts b/apps/server/src/rules/registry/whitelist.ts new file mode 100644 index 0000000..eb860fd --- /dev/null +++ b/apps/server/src/rules/registry/whitelist.ts @@ -0,0 +1,103 @@ +import { CLIENT_KINDS, type AllowRule } from "../types"; + +/* Plans/Rules Matrix — Section 1 (Whitelist), 6 layers verbatim. + * Any connection not listed is FORBIDDEN by default (default deny). */ +export const WHITELIST: AllowRule[] = [ + // 1. Client and External Access Layer (Client & Ingress) + { source: CLIENT_KINDS, edge: "REQUESTS", target: ["APIGateway", "Controller"], layer: "client", + note: "Request to the main entry gateway / directly to the API." }, + { source: CLIENT_KINDS, edge: "USES", target: "DTO", layer: "client", + note: "Clients must know the DTO structure when sending data to the API." }, + { source: "APIGateway", edge: "ROUTES_TO", target: "Controller", layer: "client", + note: "Routes requests to the relevant microservice." }, + + // 2. Processing and Presentation Layer (Presentation & Handling) + { source: "Controller", edge: "CALLS", target: ["Service", "Orchestrator"], layer: "presentation", + note: "Starts the Core Business Logic or Saga flow." }, + { source: "Controller", edge: "USES", target: "DTO", layer: "presentation", + note: "Request/Response schemas are bound." }, + { source: "Controller", edge: "RETURNS", target: "DTO", layer: "presentation", + note: "Only DTOs are exposed outward (not Model/Entity)." }, + { source: "Controller", edge: "THROWS", target: "Exception", layer: "presentation", + note: "Throws HTTP error codes." }, + { source: "Middleware", edge: "ROUTES_TO", target: "Controller", layer: "presentation", + note: "Continues the pipeline." }, + + // 3. Business Logic Layer (Business Logic) + { source: "Service", edge: "CALLS", target: ["Repository", "Service"], layer: "business", + note: "DB operations go through the Repository; service-to-service calls are allowed (must not be circular — ERR_COND_001)." }, + { source: "Service", edge: "REQUESTS", target: "ExternalService", layer: "business", + note: "External services (Stripe, AWS) are called." }, + { source: "Service", edge: "PUBLISHES", target: "MessageQueue", layer: "business", + note: "Asynchronous event emission." }, + { source: "Service", edge: "CACHES_IN", target: "Cache", layer: "business", + note: "Write frequently used data to Redis/Memcached." }, + { source: "Service", edge: "USES", target: "Model", layer: "business", + note: "Business rules are executed on the Model." }, + { source: "Service", edge: "RETURNS", target: ["DTO", "Model"], layer: "business", + note: "Both are allowed depending on the architectural choice." }, + { source: "Service", edge: "THROWS", target: "Exception", layer: "business", + note: "Business rule violation → exception." }, + { source: "Service", edge: "READS_CONFIG", target: "EnvironmentVariable", layer: "business", + note: "Settings such as API Key, DB URL, etc." }, + + // 4. Arka Plan ve Asenkron (Background & Event-Driven) + { source: "Worker", edge: "CALLS", target: "Service", layer: "background", + note: "Triggers the service when the cron time arrives." }, + { source: "EventHandler", edge: "SUBSCRIBES", target: "MessageQueue", layer: "background", + note: "Points to the queue it listens to." }, + { source: "EventHandler", edge: "CALLS", target: "Service", layer: "background", + note: "Notifies the service when the event occurs." }, + { source: "Orchestrator", edge: "CALLS", target: "Service", layer: "background", + note: "Coordinates multiple services in the Saga pattern." }, + + // 5. Data Access Layer (Data Access) + { source: "Repository", edge: "QUERIES", target: ["Table", "View"], layer: "data", + note: "SELECT operation (empty-table warning: WARN_COND_001)." }, + { source: "Repository", edge: "WRITES", target: "Table", layer: "data", + note: "INSERT/UPDATE/DELETE." }, + { source: "Repository", edge: "USES", target: "Model", layer: "data", + note: "Maps raw data from the DB to the Model (ORM)." }, + { source: "Repository", edge: "RETURNS", target: "Model", layer: "data", + note: "Exposes the Model outward (not a DTO)." }, + { source: "Repository", edge: "THROWS", target: "Exception", layer: "data", + note: "UniqueConstraint and other DB errors." }, + + // 6. Data, Schema and Inheritance (Schema & Inheritance) + { source: "Model", edge: "HAS", target: "Model", layer: "schema", + note: "Composition: Order HAS OrderItem." }, + { source: "Model", edge: "EXTENDS", target: "Model", layer: "schema", + note: "Class inheritance: AdminUser EXTENDS BaseUser." }, + { source: "Model", edge: "USES", target: "Enum", layer: "schema", + note: "Status codes, etc." }, + { source: "Model", edge: "USES", target: "Table", layer: "schema", + note: "ORM mapping — the physical Table corresponding to the Model." }, + { source: "DTO", edge: "HAS", target: "DTO", layer: "schema", + note: "Nested DTO." }, + { source: "DTO", edge: "USES", target: "Enum", layer: "schema", + note: "Validation and type determination." }, + { source: "Table", edge: "USES", target: "Enum", layer: "schema", + note: "ENUM columns at the DB level." }, + { source: "Exception", edge: "EXTENDS", target: "Exception", layer: "schema", + note: "NotFoundError EXTENDS BaseError." }, + { source: "Service", edge: "IMPLEMENTS", target: "Service", layer: "schema", + note: "A service implements another service acting as an interface/contract (PaymentService IMPLEMENTS IPaymentService). Since there is no separate Interface node type, the interface is also modeled as a Service." }, + + // 7. UI Composition (Frontend) + { source: "FrontendApp", edge: "HAS", target: "UIComponent", layer: "client", + note: "The frontend app contains pages/components." }, + { source: "UIComponent", edge: "HAS", target: "UIComponent", layer: "client", + note: "Nested component composition: PageLayout HAS Header/Sidebar." }, + + // 8. Modular Architecture (Bounded Context) + { source: "Module", edge: "DEPENDS_ON", target: "Module", layer: "structure", + note: "Module hierarchy — bounded context dependency graph." }, + { source: "Module", edge: "USES", target: "Service", layer: "structure", + note: "Services exposed by the Module (public API surface)." }, + + // 9. Schema (parameter/format) references + { source: "Service", edge: "USES", target: "DTO", layer: "business", + note: "Method parameter or nested DTO reference." }, + { source: "MessageQueue", edge: "USES", target: "DTO", layer: "background", + note: "Message format DTO reference." }, +]; diff --git a/apps/server/src/rules/review.controller.ts b/apps/server/src/rules/review.controller.ts new file mode 100644 index 0000000..1933714 --- /dev/null +++ b/apps/server/src/rules/review.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Param, Post, HttpCode, UseGuards, NotFoundException } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiParam, ApiResponse } from "@nestjs/swagger"; +import { ProjectAccessGuard } from "../auth/project-access.guard"; +import { ProjectsRepository } from "../projects/projects.repository"; +import { RulesEngine } from "./rules.engine"; +import { ok } from "../common/envelope"; +import type { NodeKind } from "../nodes/schemas"; +import type { EdgeKind } from "../edges/schemas/edge.schema"; + +/** "Verify my architecture" — whole-graph rule review. + * ProjectAccessGuard + ProjectsRepository @Global AuthModule'den gelir; + * ekstra module import gerekmez. */ +@ApiTags("Rules") +@UseGuards(ProjectAccessGuard) +@Controller("projects/:projectId") +export class ReviewController { + constructor( + private readonly engine: RulesEngine, + private readonly projects: ProjectsRepository, + ) {} + + @Post("review") + @HttpCode(200) + @ApiOperation({ + summary: "Verify the architecture (whole-graph rule review)", + description: + "Re-evaluates every existing edge against the Rules Engine (blacklist → default-deny whitelist → " + + "conditional checks) and returns a ranked Problems list (errors first). Deterministic — no LLM, no mutation. " + + "Response: `data: { findings, summary: { total, errors, warnings, clean } }`.", + }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + @ApiResponse({ status: 200, description: "Ranked findings + summary." }) + @ApiResponse({ status: 404, description: "`ERR_PROJECT_NOT_FOUND`." }) + async review(@Param("projectId") projectId: string) { + if (!(await this.projects.exists(projectId))) { + throw new NotFoundException({ code: "ERR_PROJECT_NOT_FOUND", message: `Project '${projectId}' not found.` }); + } + const { nodes, edges } = await this.projects.getGraph(projectId); + const findings = await this.engine.reviewGraph( + projectId, + nodes.map((n) => ({ id: n.id, type: n.type as NodeKind, properties: n.properties as Record })), + edges.map((e) => ({ id: e.id, sourceNodeId: e.sourceNodeId, targetNodeId: e.targetNodeId, kind: e.kind as EdgeKind })), + ); + const errors = findings.filter((f) => f.severity === "error").length; + const warnings = findings.filter((f) => f.severity === "warning").length; + return ok({ findings, summary: { total: findings.length, errors, warnings, clean: findings.length === 0 } }); + } +} diff --git a/apps/server/src/rules/rules.controller.ts b/apps/server/src/rules/rules.controller.ts new file mode 100644 index 0000000..8079232 --- /dev/null +++ b/apps/server/src/rules/rules.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; +import { RulesEngine } from "./rules.engine"; +import { ok } from "../common/envelope"; +import { RULE_LAYER_LABELS } from "./types"; + +@ApiTags("Rules") +@Controller("rules") +export class RulesController { + constructor(private readonly engine: RulesEngine) {} + + @Get() + @ApiOperation({ + summary: "Architecture rule catalog", + description: + "Returns all rules of the Rules Engine:\n\n" + + "- **whitelist** (~32 rules): allowed source→edge→target combinations, split into 6 layers\n" + + "- **blacklist** (7 rules): hard prohibitions `ERR_001..ERR_007` (message + suggestion)\n" + + "- **conditional** (3 rules): circular dependency, type mismatch, empty schema\n" + + "- **defaults**: unspecified connections are `deny` (forbidden by default)\n\n" + + "Consult this catalog to learn which connection can be created between two nodes.", + }) + @ApiResponse({ status: 200, description: "whitelist + blacklist + conditional + layers + counts." }) + catalog() { + const c = this.engine.catalog(); + return ok({ + ...c, + layers: RULE_LAYER_LABELS, + counts: { + whitelist: c.whitelist.length, + blacklist: c.blacklist.length, + conditional: c.conditional.length, + }, + }); + } +} diff --git a/apps/server/src/rules/rules.engine.spec.ts b/apps/server/src/rules/rules.engine.spec.ts new file mode 100644 index 0000000..ceac2f9 --- /dev/null +++ b/apps/server/src/rules/rules.engine.spec.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { RulesEngine } from "./rules.engine"; +import { CircularDependencyChecker } from "./checkers/circular-dependency.checker"; +import { TypeMismatchChecker } from "./checkers/type-mismatch.checker"; +import { EmptySchemaChecker } from "./checkers/empty-schema.checker"; +import type { StoredNode } from "../nodes/nodes.repository"; +import type { EvaluationContext } from "./types"; + +function makeNode(type: string, id: string, properties: any = {}): StoredNode { + return { + id, + type: type as any, + projectId: "p1", + positionX: 0, + positionY: 0, + createdAt: "2026-05-22T10:00:00.000Z", + updatedAt: "2026-05-22T10:00:00.000Z", + properties, + }; +} + +function ctx(source: StoredNode, edgeKind: any, target: StoredNode): EvaluationContext { + return { projectId: "p1", sourceNode: source, targetNode: target, edgeKind }; +} + +describe("RulesEngine", () => { + let engine: RulesEngine; + let circular: CircularDependencyChecker; + + beforeEach(() => { + circular = { check: vi.fn(async () => ({ allowed: true })) } as any; + const typeMismatch = new TypeMismatchChecker(); + const emptySchema = new EmptySchemaChecker(); + engine = new RulesEngine(circular, typeMismatch, emptySchema); + }); + + describe("whitelist", () => { + it("Controller → CALLS → Service allowed", async () => { + const r = await engine.evaluate(ctx(makeNode("Controller", "c1"), "CALLS", makeNode("Service", "s1"))); + expect(r.allowed).toBe(true); + }); + + it("Service → CALLS → Repository allowed", async () => { + const r = await engine.evaluate(ctx(makeNode("Service", "s1"), "CALLS", makeNode("Repository", "r1"))); + expect(r.allowed).toBe(true); + }); + + it("Repository → QUERIES → Table allowed (Columns populated)", async () => { + const r = await engine.evaluate(ctx( + makeNode("Repository", "r1"), + "QUERIES", + makeNode("Table", "t1", { Columns: [{ Name: "id" }] }), + )); + expect(r.allowed).toBe(true); + }); + + it("Service → IMPLEMENTS → Service allowed (interface/contract)", async () => { + const r = await engine.evaluate(ctx(makeNode("Service", "s1"), "IMPLEMENTS", makeNode("Service", "s2"))); + expect(r.allowed).toBe(true); + }); + }); + + describe("blacklist", () => { + it("ERR_001: FrontendApp → REQUESTS → Table", async () => { + const r = await engine.evaluate(ctx(makeNode("FrontendApp", "f1"), "REQUESTS", makeNode("Table", "t1"))); + expect(r.allowed).toBe(false); + expect(r.code).toBe("ERR_001"); + }); + + it("ERR_002: Controller → WRITES → Table", async () => { + const r = await engine.evaluate(ctx(makeNode("Controller", "c1"), "WRITES", makeNode("Table", "t1"))); + expect(r.allowed).toBe(false); + expect(r.code).toBe("ERR_002"); + }); + + it("ERR_003: Table → USES → Service (data is passive)", async () => { + const r = await engine.evaluate(ctx(makeNode("Table", "t1"), "USES", makeNode("Service", "s1"))); + expect(r.allowed).toBe(false); + expect(r.code).toBe("ERR_003"); + }); + + it("ERR_004: DTO → HAS → Model", async () => { + const r = await engine.evaluate(ctx(makeNode("DTO", "d1"), "HAS", makeNode("Model", "m1"))); + expect(r.allowed).toBe(false); + expect(r.code).toBe("ERR_004"); + }); + + it("ERR_005: Service → REQUESTS → FrontendApp", async () => { + const r = await engine.evaluate(ctx(makeNode("Service", "s1"), "REQUESTS", makeNode("FrontendApp", "f1"))); + expect(r.allowed).toBe(false); + expect(r.code).toBe("ERR_005"); + }); + + it("ERR_006: APIGateway → ROUTES_TO → Repository", async () => { + const r = await engine.evaluate(ctx(makeNode("APIGateway", "g1"), "ROUTES_TO", makeNode("Repository", "r1"))); + expect(r.allowed).toBe(false); + expect(r.code).toBe("ERR_006"); + }); + + it("ERR_007: EventHandler → RETURNS → DTO", async () => { + const r = await engine.evaluate(ctx(makeNode("EventHandler", "e1"), "RETURNS", makeNode("DTO", "d1"))); + expect(r.allowed).toBe(false); + expect(r.code).toBe("ERR_007"); + }); + }); + + describe("default deny (no whitelist match + no blacklist match)", () => { + it("Worker → REQUESTS → ExternalService → ERR_NOT_WHITELISTED", async () => { + // Worker's REQUESTS edge is not in whitelist and does not hit blacklist. + const r = await engine.evaluate(ctx(makeNode("Worker", "w1"), "REQUESTS", makeNode("ExternalService", "x1"))); + expect(r.allowed).toBe(false); + expect(r.code).toBe("ERR_NOT_WHITELISTED"); + }); + + it("Controller → IMPLEMENTS → Service → ERR_NOT_WHITELISTED (only Service→Service allowed)", async () => { + const r = await engine.evaluate(ctx(makeNode("Controller", "c1"), "IMPLEMENTS", makeNode("Service", "s1"))); + expect(r.allowed).toBe(false); + expect(r.code).toBe("ERR_NOT_WHITELISTED"); + }); + }); + + describe("conditional", () => { + it("ERR_COND_002: Controller (UserDTO) → Service (OrderDTO)", async () => { + const ctrl = makeNode("Controller", "c1", { + Endpoints: [{ HttpMethod: "POST", Route: "/", RequestDTORef: "UserDTO", RequiresAuth: false }], + }); + const srv = makeNode("Service", "s1", { + Methods: [{ MethodName: "doX", Parameters: [{ Name: "p", Type: "OrderDTO" }], ReturnType: "void" }], + }); + const r = await engine.evaluate(ctx(ctrl, "CALLS", srv)); + expect(r.allowed).toBe(false); + expect(r.code).toBe("ERR_COND_002"); + }); + + it("Controller (UserDTO) → Service (UserDTO) allowed", async () => { + const ctrl = makeNode("Controller", "c1", { + Endpoints: [{ HttpMethod: "POST", Route: "/", RequestDTORef: "UserDTO", RequiresAuth: false }], + }); + const srv = makeNode("Service", "s1", { + Methods: [{ MethodName: "doX", Parameters: [{ Name: "p", Type: "UserDTO" }], ReturnType: "void" }], + }); + const r = await engine.evaluate(ctx(ctrl, "CALLS", srv)); + expect(r.allowed).toBe(true); + }); + + it("WARN_COND_001: Repository → QUERIES → empty Table", async () => { + const r = await engine.evaluate(ctx( + makeNode("Repository", "r1"), + "QUERIES", + makeNode("Table", "t1", { Columns: [], TableName: "empty_t" }), + )); + expect(r.allowed).toBe(true); + expect(r.severity).toBe("warning"); + expect(r.code).toBe("WARN_COND_001"); + }); + + it("ERR_COND_001 delegates to circular checker", async () => { + circular.check = vi.fn(async () => ({ + allowed: false, + code: "ERR_COND_001", + severity: "error", + ruleViolated: "CIRCULAR_DEPENDENCY", + message: "cycle", + })); + const r = await engine.evaluate(ctx(makeNode("Service", "a"), "CALLS", makeNode("Service", "b"))); + expect(r.allowed).toBe(false); + expect(r.code).toBe("ERR_COND_001"); + }); + }); + + describe("rulesFor* + catalog", () => { + it("rulesForNodeKind('Service') returns allowAsSource/Target lists", () => { + const r = engine.rulesForNodeKind("Service"); + expect(r.allowAsSource.length).toBeGreaterThan(0); + expect(r.allowAsTarget.length).toBeGreaterThan(0); + }); + + it("rulesForEdgeKind('CALLS') includes deny list", () => { + const r = engine.rulesForEdgeKind("CALLS"); + // ERR_006 APIGateway CALLS Repository — deny rule + expect(r.deny.some((d) => d.code === "ERR_006")).toBe(true); + }); + + it("catalog() returns whitelist/blacklist/conditional counts", () => { + const c = engine.catalog(); + expect(c.whitelist.length).toBeGreaterThanOrEqual(30); + expect(c.blacklist.length).toBe(7); + expect(c.conditional.length).toBe(3); + expect(c.defaults.unmatchedBehavior).toBe("deny"); + }); + }); +}); diff --git a/apps/server/src/rules/rules.engine.ts b/apps/server/src/rules/rules.engine.ts new file mode 100644 index 0000000..c1fd5e6 --- /dev/null +++ b/apps/server/src/rules/rules.engine.ts @@ -0,0 +1,207 @@ +import { Injectable } from "@nestjs/common"; +import type { NodeKind } from "../nodes/schemas"; +import type { EdgeKind } from "../edges/schemas/edge.schema"; +import type { StoredNode } from "../nodes/nodes.repository"; +import { WHITELIST } from "./registry/whitelist"; +import { BLACKLIST } from "./registry/blacklist"; +import { CONDITIONAL_RULES } from "./registry/conditional"; +import { CircularDependencyChecker } from "./checkers/circular-dependency.checker"; +import { TypeMismatchChecker } from "./checkers/type-mismatch.checker"; +import { EmptySchemaChecker } from "./checkers/empty-schema.checker"; +import type { + AllowRule, + DenyRule, + EvaluationContext, + EvaluationResult, + NodeKindOrWildcard, + EdgeKindOrWildcard, + ReviewFinding, +} from "./types"; + +@Injectable() +export class RulesEngine { + constructor( + private readonly circularChecker: CircularDependencyChecker, + private readonly typeMismatchChecker: TypeMismatchChecker, + private readonly emptySchemaChecker: EmptySchemaChecker, + ) {} + + /** 3-phase evaluator: blacklist → whitelist (default deny) → conditional. */ + async evaluate(ctx: EvaluationContext): Promise { + // 1. Blacklist — hard deny + const denyHit = this.checkBlacklist(ctx); + if (denyHit) return denyHit; + + // 2. Whitelist — default deny + const allowHit = this.checkWhitelist(ctx); + if (!allowHit) { + return { + allowed: false, + severity: "error", + code: "ERR_NOT_WHITELISTED", + ruleViolated: `${ctx.sourceNode.type} → ${ctx.edgeKind} → ${ctx.targetNode.type}`, + message: `The '${ctx.sourceNode.type} → ${ctx.edgeKind} → ${ctx.targetNode.type}' combination is not permitted in the plans/Rules Matrix. Per the plans: any connection that is not explicitly specified is FORBIDDEN by default.`, + suggestion: + "Use GET /api/v1/rules to see the allowed (whitelist) connections and build the correct chain (e.g. Controller → Service → Repository → Table).", + }; + } + + // 3. Conditional — deep checks + const circular = await this.circularChecker.check(ctx); + if (!circular.allowed) return circular; + + const typeMismatch = this.typeMismatchChecker.check(ctx); + if (!typeMismatch.allowed) return typeMismatch; + + const emptySchema = this.emptySchemaChecker.check(ctx); + if (emptySchema.severity === "warning") return emptySchema; + + return { allowed: true }; + } + + /** Whole-graph review — runs every existing edge through Rules Engine and returns + * sorted Problems list (errors first). Deterministic; no LLM, no mutation. + * "Verify my architecture" Pass-1. evaluate() is cheap for most edges + * (circular Neo4j query only on Service→Service CALLS). */ + async reviewGraph( + projectId: string, + nodes: { id: string; type: NodeKind; properties: Record }[], + edges: { id: string; sourceNodeId: string; targetNodeId: string; kind: EdgeKind }[], + ): Promise { + const byId = new Map(nodes.map((n) => [n.id, n])); + const findings: ReviewFinding[] = []; + for (const e of edges) { + const source = byId.get(e.sourceNodeId); + const target = byId.get(e.targetNodeId); + if (!source || !target) { + findings.push({ + severity: "error", + code: "ERR_DANGLING_EDGE", + message: `Edge references a node that no longer exists (${e.sourceNodeId} -[${e.kind}]-> ${e.targetNodeId}).`, + suggestion: "Delete this edge or restore the missing node.", + edgeId: e.id, + edgeKind: e.kind, + nodeIds: [e.sourceNodeId, e.targetNodeId], + }); + continue; + } + const result = await this.evaluate({ + projectId, + sourceNode: source as unknown as StoredNode, + targetNode: target as unknown as StoredNode, + edgeKind: e.kind, + }); + if (result.code) { + findings.push({ + severity: result.severity ?? "error", + code: result.code, + message: result.message ?? "Rule violation.", + suggestion: result.suggestion, + ruleViolated: result.ruleViolated, + docLink: result.docLink, + edgeId: e.id, + edgeKind: e.kind, + nodeIds: [source.id, target.id], + }); + } + } + findings.sort((a, b) => (a.severity === b.severity ? 0 : a.severity === "error" ? -1 : 1)); + return findings; + } + + /** All whitelist + blacklist rules relevant to a given node kind. */ + rulesForNodeKind(kind: NodeKind) { + const allowAsSource = WHITELIST.filter((r) => matchesKind(r.source, kind)); + const allowAsTarget = WHITELIST.filter((r) => matchesKind(r.target, kind)); + const denyAsSource = BLACKLIST.filter((r) => matchesDenyKind(r.source, kind)); + const denyAsTarget = BLACKLIST.filter((r) => matchesDenyKind(r.target, kind)); + return { allowAsSource, allowAsTarget, denyAsSource, denyAsTarget }; + } + + /** All whitelist + blacklist rules relevant to a given edge kind. */ + rulesForEdgeKind(kind: EdgeKind) { + const allow = WHITELIST.filter((r) => matchesEdge(r.edge, kind)); + const deny = BLACKLIST.filter((r) => matchesDenyEdge(r.edge, kind)); + return { allow, deny }; + } + + catalog() { + return { + whitelist: WHITELIST, + blacklist: BLACKLIST, + conditional: CONDITIONAL_RULES, + defaults: { + unmatchedBehavior: "deny", + reason: "Plans/Rules Matrix: any connection that is not explicitly specified is FORBIDDEN.", + }, + }; + } + + private checkBlacklist(ctx: EvaluationContext): EvaluationResult | null { + for (const rule of BLACKLIST) { + if ( + matchesDenyKind(rule.source, ctx.sourceNode.type) && + matchesDenyEdge(rule.edge, ctx.edgeKind) && + matchesDenyKind(rule.target, ctx.targetNode.type) + ) { + return { + allowed: false, + severity: "error", + code: rule.code, + ruleViolated: `${asStr(rule.source)} → ${asStr(rule.edge)} → ${asStr(rule.target)}`, + message: rule.message, + suggestion: rule.suggestion, + docLink: rule.docLink, + }; + } + } + return null; + } + + private checkWhitelist(ctx: EvaluationContext): AllowRule | null { + for (const rule of WHITELIST) { + if ( + matchesKind(rule.source, ctx.sourceNode.type) && + matchesEdge(rule.edge, ctx.edgeKind) && + matchesKind(rule.target, ctx.targetNode.type) + ) { + return rule; + } + } + return null; + } +} + +function matchesKind(spec: NodeKind | NodeKind[], target: NodeKind): boolean { + return Array.isArray(spec) ? spec.includes(target) : spec === target; +} + +function matchesEdge(spec: EdgeKind | EdgeKind[], target: EdgeKind): boolean { + return Array.isArray(spec) ? spec.includes(target) : spec === target; +} + +function matchesDenyKind( + spec: NodeKindOrWildcard | NodeKindOrWildcard[], + target: NodeKind, +): boolean { + if (spec === "*") return true; + if (Array.isArray(spec)) return spec.includes("*") || spec.includes(target); + return spec === target; +} + +function matchesDenyEdge( + spec: EdgeKindOrWildcard | EdgeKindOrWildcard[], + target: EdgeKind, +): boolean { + if (spec === "*") return true; + if (Array.isArray(spec)) return spec.includes("*") || spec.includes(target); + return spec === target; +} + +function asStr(v: string | string[]): string { + return Array.isArray(v) ? `[${v.join("|")}]` : v; +} + +function asStrAny(v: unknown): string { + return typeof v === "string" ? v : Array.isArray(v) ? `[${v.join("|")}]` : String(v); +} diff --git a/apps/server/src/rules/rules.module.ts b/apps/server/src/rules/rules.module.ts new file mode 100644 index 0000000..ffd594a --- /dev/null +++ b/apps/server/src/rules/rules.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { RulesEngine } from "./rules.engine"; +import { RulesController } from "./rules.controller"; +import { ReviewController } from "./review.controller"; +import { CircularDependencyChecker } from "./checkers/circular-dependency.checker"; +import { TypeMismatchChecker } from "./checkers/type-mismatch.checker"; +import { EmptySchemaChecker } from "./checkers/empty-schema.checker"; + +@Module({ + controllers: [RulesController, ReviewController], + providers: [RulesEngine, CircularDependencyChecker, TypeMismatchChecker, EmptySchemaChecker], + exports: [RulesEngine], +}) +export class RulesModule {} diff --git a/apps/server/src/rules/types.ts b/apps/server/src/rules/types.ts new file mode 100644 index 0000000..2ca9b55 --- /dev/null +++ b/apps/server/src/rules/types.ts @@ -0,0 +1,100 @@ +import type { NodeKind } from "../nodes/schemas"; +import type { EdgeKind } from "../edges/schemas/edge.schema"; +import type { StoredNode } from "../nodes/nodes.repository"; + +export type NodeKindOrWildcard = NodeKind | "*"; +export type EdgeKindOrWildcard = EdgeKind | "*"; + +/** Whitelist allow rule — Plans/Rules Matrix permitted connections. */ +export interface AllowRule { + source: NodeKind | NodeKind[]; + edge: EdgeKind | EdgeKind[]; + target: NodeKind | NodeKind[]; + layer: RuleLayer; + note?: string; +} + +/** Blacklist deny rule — Plans/Rules Matrix hard prohibitions (ERR_001..007). */ +export interface DenyRule { + code: string; + source: NodeKindOrWildcard | NodeKindOrWildcard[]; + edge: EdgeKindOrWildcard | EdgeKindOrWildcard[]; + target: NodeKindOrWildcard | NodeKindOrWildcard[]; + message: string; + suggestion: string; + docLink?: string; +} + +/** Conditional rule descriptor — checkers run at runtime. */ +export interface ConditionalRuleDescriptor { + code: string; + type: "CIRCULAR_DEPENDENCY" | "TYPE_MISMATCH" | "EMPTY_SCHEMA"; + severity: "error" | "warning"; + description: string; + appliesWhen: string; +} + +export type RuleLayer = + | "client" + | "presentation" + | "business" + | "background" + | "data" + | "schema" + | "structure"; + +export const RULE_LAYER_LABELS: Record = { + client: "1. Client and External Access", + presentation: "2. Processing and Presentation", + business: "3. Business Logic", + background: "4. Background and Async", + data: "5. Data Access", + schema: "6. Data, Schema and Inheritance", + structure: "7. Modular Structure", +}; + +export interface EvaluationContext { + projectId: string; + sourceNode: StoredNode; + targetNode: StoredNode; + edgeKind: EdgeKind; +} + +export interface EvaluationResult { + allowed: boolean; + severity?: "error" | "warning"; + code?: string; + ruleViolated?: string; + message?: string; + suggestion?: string; + docLink?: string; +} + +/** Whole-graph review finding — rule violation/warning on an existing edge. + * Returned by POST /projects/:id/review; frontend Problems panel displays it. */ +export interface ReviewFinding { + severity: "error" | "warning"; + code: string; + message: string; + suggestion?: string; + ruleViolated?: string; + docLink?: string; + edgeId: string; + edgeKind: EdgeKind; + /** [sourceId, targetId] — for frontend focusNode/focusEdge. */ + nodeIds: string[]; +} + +export const CLIENT_KINDS: NodeKind[] = ["FrontendApp", "UIComponent"]; +export const DATA_KINDS: NodeKind[] = ["Table", "View", "DTO", "Enum", "Model"]; +export const PASSIVE_KINDS: NodeKind[] = ["Table", "View", "DTO", "Enum"]; +export const ACTIVE_KINDS: NodeKind[] = [ + "Service", + "Controller", + "APIGateway", + "Worker", + "EventHandler", + "Orchestrator", + "Repository", + "Middleware", +]; diff --git a/apps/server/src/tabs/dto/create-tab.dto.ts b/apps/server/src/tabs/dto/create-tab.dto.ts new file mode 100644 index 0000000..36ab78d --- /dev/null +++ b/apps/server/src/tabs/dto/create-tab.dto.ts @@ -0,0 +1,4 @@ +import { createZodDto } from "nestjs-zod"; +import { CreateTabSchema } from "../schemas/tab.schema"; + +export class CreateTabDto extends createZodDto(CreateTabSchema) {} diff --git a/apps/server/src/tabs/dto/layout.dto.ts b/apps/server/src/tabs/dto/layout.dto.ts new file mode 100644 index 0000000..dda3c5b --- /dev/null +++ b/apps/server/src/tabs/dto/layout.dto.ts @@ -0,0 +1,4 @@ +import { createZodDto } from "nestjs-zod"; +import { LayoutSchema } from "../schemas/tab.schema"; + +export class LayoutDto extends createZodDto(LayoutSchema) {} diff --git a/apps/server/src/tabs/dto/reference.dto.ts b/apps/server/src/tabs/dto/reference.dto.ts new file mode 100644 index 0000000..60d573f --- /dev/null +++ b/apps/server/src/tabs/dto/reference.dto.ts @@ -0,0 +1,4 @@ +import { createZodDto } from "nestjs-zod"; +import { ReferenceSchema } from "../schemas/tab.schema"; + +export class ReferenceDto extends createZodDto(ReferenceSchema) {} diff --git a/apps/server/src/tabs/dto/update-tab.dto.ts b/apps/server/src/tabs/dto/update-tab.dto.ts new file mode 100644 index 0000000..a795d44 --- /dev/null +++ b/apps/server/src/tabs/dto/update-tab.dto.ts @@ -0,0 +1,4 @@ +import { createZodDto } from "nestjs-zod"; +import { UpdateTabSchema } from "../schemas/tab.schema"; + +export class UpdateTabDto extends createZodDto(UpdateTabSchema) {} diff --git a/apps/server/src/tabs/schemas/tab.schema.spec.ts b/apps/server/src/tabs/schemas/tab.schema.spec.ts new file mode 100644 index 0000000..9992dc6 --- /dev/null +++ b/apps/server/src/tabs/schemas/tab.schema.spec.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { CreateTabSchema, LayoutSchema } from "./tab.schema"; + +describe("Tab schemas", () => { + it("CreateTab valid", () => { + expect(CreateTabSchema.parse({ name: "Order Module" }).name).toBe("Order Module"); + }); + it("CreateTab rejects empty name", () => { + expect(() => CreateTabSchema.parse({ name: "" })).toThrow(); + }); + it("Layout rejects empty items", () => { + expect(() => LayoutSchema.parse({ items: [] })).toThrow(); + }); + it("Layout accepts valid item", () => { + expect(LayoutSchema.parse({ items: [{ nodeId: "550e8400-e29b-41d4-a716-446655440000", x: 1, y: 2 }] }).items).toHaveLength(1); + }); +}); diff --git a/apps/server/src/tabs/schemas/tab.schema.ts b/apps/server/src/tabs/schemas/tab.schema.ts new file mode 100644 index 0000000..dc47d10 --- /dev/null +++ b/apps/server/src/tabs/schemas/tab.schema.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; + +export const CreateTabSchema = z.object({ + name: z.string().min(1), + moduleNodeId: z.string().uuid().optional(), +}).strict(); + +export const UpdateTabSchema = z.object({ + name: z.string().min(1).optional(), + order: z.number().int().nonnegative().optional(), +}).strict(); + +export const ReferenceSchema = z.object({ + x: z.number(), + y: z.number(), +}).strict(); + +export const LayoutSchema = z.object({ + items: z.array(z.object({ + nodeId: z.string().uuid(), + x: z.number(), + y: z.number(), + }).strict()).min(1), +}).strict(); + +export type CreateTabInput = z.infer; +export type UpdateTabInput = z.infer; +export type ReferenceInput = z.infer; +export type LayoutInput = z.infer; + +export interface StoredTab { + id: string; + projectId: string; + name: string; + isDefault: boolean; + order: number; + moduleNodeId?: string; + createdAt: string; + updatedAt: string; +} + +/** A tab's render content: positioned nodes + edges between them. */ +export interface TabGraphMember { + id: string; + type: string; + properties: Record; + position: { x: number; y: number }; + version: number; // optimistic concurrency — frontend autosave sends this as expectedVersion + isReference: boolean; + origin?: string; // when reference: node's home tab (homeTabId) + // Implementation counters (CLI/extension report) — canvas fill badge. + implTotal?: number; + implFilled?: number; + implAi?: number; +} +export interface TabGraphEdge { + id: string; + kind: string; + sourceNodeId: string; + targetNodeId: string; +} +export interface TabGraph { + tab: StoredTab; + nodes: TabGraphMember[]; + edges: TabGraphEdge[]; +} diff --git a/apps/server/src/tabs/tabs.controller.spec.ts b/apps/server/src/tabs/tabs.controller.spec.ts new file mode 100644 index 0000000..b1e784c --- /dev/null +++ b/apps/server/src/tabs/tabs.controller.spec.ts @@ -0,0 +1,16 @@ +import { describe, it, expect, vi } from "vitest"; +import { TabsController } from "./tabs.controller"; + +describe("TabsController", () => { + const service = { create: vi.fn().mockResolvedValue({ id: "t" }), addReference: vi.fn(), saveLayout: vi.fn() }; + const c = new TabsController(service as any); + + it("create returns envelope", async () => { + expect(await c.create("p", { name: "X" } as any)).toEqual({ success: true, data: { id: "t" } }); + }); + it("addReference passes x/y + envelope", async () => { + const r = await c.addReference("p", "t", "n", { x: 3, y: 4 } as any); + expect(service.addReference).toHaveBeenCalledWith("p", "t", "n", 3, 4); + expect(r).toEqual({ success: true, data: { tabId: "t", nodeId: "n", x: 3, y: 4 } }); + }); +}); diff --git a/apps/server/src/tabs/tabs.controller.ts b/apps/server/src/tabs/tabs.controller.ts new file mode 100644 index 0000000..81df036 --- /dev/null +++ b/apps/server/src/tabs/tabs.controller.ts @@ -0,0 +1,85 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Put, UseGuards } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiParam, ApiResponse } from "@nestjs/swagger"; +import { ProjectAccessGuard } from "../auth/project-access.guard"; +import { TabsService } from "./tabs.service"; +import { CreateTabDto } from "./dto/create-tab.dto"; +import { UpdateTabDto } from "./dto/update-tab.dto"; +import { ReferenceDto } from "./dto/reference.dto"; +import { LayoutDto } from "./dto/layout.dto"; +import { ok } from "../common/envelope"; + +@ApiTags("Tabs (Contexts)") +@UseGuards(ProjectAccessGuard) +@Controller("projects/:projectId/tabs") +export class TabsController { + constructor(private readonly service: TabsService) {} + + @Post() + @ApiOperation({ summary: "Create tab", description: "New context/canvas tab. moduleNodeId is optional (drill-down source)." }) + @ApiParam({ name: "projectId", description: "Project UUID" }) + async create(@Param("projectId") projectId: string, @Body() body: CreateTabDto) { + return ok(await this.service.create(projectId, body as any)); + } + + @Get() + @ApiOperation({ summary: "List tabs", description: "Sorted by order." }) + async list(@Param("projectId") projectId: string) { + return ok(await this.service.list(projectId)); + } + + @Get(":tabId") + @ApiOperation({ summary: "Tab detail" }) + @ApiResponse({ status: 404, description: "ERR_TAB_NOT_FOUND" }) + async getById(@Param("projectId") projectId: string, @Param("tabId") tabId: string) { + return ok(await this.service.getById(projectId, tabId)); + } + + @Get(":tabId/graph") + @ApiOperation({ summary: "Tab render content", description: "owned + referenced nodes (position + origin) + edges between them." }) + async graph(@Param("projectId") projectId: string, @Param("tabId") tabId: string) { + return ok(await this.service.tabGraph(projectId, tabId)); + } + + @Patch(":tabId") + @ApiOperation({ summary: "Update tab (name/order)" }) + async update(@Param("projectId") projectId: string, @Param("tabId") tabId: string, @Body() body: UpdateTabDto) { + return ok(await this.service.update(projectId, tabId, body as any)); + } + + @Delete(":tabId") + @HttpCode(204) + @ApiOperation({ summary: "Delete tab", description: "The default cannot be deleted. Owned nodes are moved to Main Architecture, references are removed." }) + @ApiResponse({ status: 400, description: "ERR_TAB_DEFAULT_DELETE" }) + async delete(@Param("projectId") projectId: string, @Param("tabId") tabId: string) { + await this.service.delete(projectId, tabId); + } + + @Put(":tabId/references/:nodeId") + @ApiOperation({ summary: "Import node into tab / update reference position" }) + @ApiResponse({ status: 400, description: "ERR_TAB_SELF_REFERENCE" }) + async addReference( + @Param("projectId") projectId: string, + @Param("tabId") tabId: string, + @Param("nodeId") nodeId: string, + @Body() body: ReferenceDto, + ) { + const { x, y } = body as any; + await this.service.addReference(projectId, tabId, nodeId, x, y); + return ok({ tabId, nodeId, x, y }); + } + + @Delete(":tabId/references/:nodeId") + @HttpCode(204) + @ApiOperation({ summary: "Remove reference (node is not deleted)" }) + async removeReference(@Param("projectId") projectId: string, @Param("tabId") tabId: string, @Param("nodeId") nodeId: string) { + await this.service.removeReference(projectId, tabId, nodeId); + } + + @Patch(":tabId/layout") + @ApiOperation({ summary: "Save batch positions", description: "After drag: owned → node position, referenced → reference position." }) + async layout(@Param("projectId") projectId: string, @Param("tabId") tabId: string, @Body() body: LayoutDto) { + const { items } = body as any; + await this.service.saveLayout(projectId, tabId, items); + return ok({ tabId, updated: items.length }); + } +} diff --git a/apps/server/src/tabs/tabs.module.ts b/apps/server/src/tabs/tabs.module.ts new file mode 100644 index 0000000..692e753 --- /dev/null +++ b/apps/server/src/tabs/tabs.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { Neo4jModule } from "../neo4j/neo4j.module"; +import { TabsController } from "./tabs.controller"; +import { TabsService } from "./tabs.service"; +import { TabsRepository } from "./tabs.repository"; + +@Module({ + imports: [Neo4jModule], + controllers: [TabsController], + providers: [TabsService, TabsRepository], + exports: [TabsService, TabsRepository], +}) +export class TabsModule {} diff --git a/apps/server/src/tabs/tabs.repository.spec.ts b/apps/server/src/tabs/tabs.repository.spec.ts new file mode 100644 index 0000000..d5ebe59 --- /dev/null +++ b/apps/server/src/tabs/tabs.repository.spec.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TabsRepository } from "./tabs.repository"; + +const neo4j = { run: vi.fn() }; +const repo = new TabsRepository(neo4j as any); + +describe("TabsRepository", () => { + beforeEach(() => neo4j.run.mockReset()); + + it("upsertReference uses MERGE", async () => { + neo4j.run.mockResolvedValueOnce({ records: [] }); + await repo.upsertReference("p", "t", "n", 10, 20); + expect(neo4j.run.mock.calls[0][0]).toContain("MERGE (t)-[r:REFERENCES]->(n)"); + expect(neo4j.run.mock.calls[0][1]).toMatchObject({ projectId: "p", tabId: "t", nodeId: "n", x: 10, y: 20 }); + }); + + it("findDefault filters isDefault:true", async () => { + neo4j.run.mockResolvedValueOnce({ records: [] }); + expect(await repo.findDefault("p")).toBeNull(); + expect(neo4j.run.mock.calls[0][0]).toContain("isDefault: true"); + }); + + it("removeReference returns false when missing", async () => { + neo4j.run.mockResolvedValueOnce({ records: [] }); + expect(await repo.removeReference("p", "t", "n")).toBe(false); + }); +}); diff --git a/apps/server/src/tabs/tabs.repository.ts b/apps/server/src/tabs/tabs.repository.ts new file mode 100644 index 0000000..1f679db --- /dev/null +++ b/apps/server/src/tabs/tabs.repository.ts @@ -0,0 +1,213 @@ +import { Injectable } from "@nestjs/common"; +import { Neo4jService } from "../neo4j/neo4j.service"; +import type { StoredTab, TabGraph, TabGraphMember, TabGraphEdge } from "./schemas/tab.schema"; + +@Injectable() +export class TabsRepository { + constructor(private readonly neo4j: Neo4jService) {} + + async projectExists(projectId: string): Promise { + const r = await this.neo4j.run(`MATCH (p:Project {id: $projectId}) RETURN p LIMIT 1`, { projectId }); + return r.records.length > 0; + } + + async nodeExists(projectId: string, nodeId: string): Promise { + const r = await this.neo4j.run( + `MATCH (n:Node {id: $nodeId, projectId: $projectId}) RETURN n LIMIT 1`, + { projectId, nodeId }, + ); + return r.records.length > 0; + } + + async nodeHomeTab(projectId: string, nodeId: string): Promise { + const r = await this.neo4j.run( + `MATCH (n:Node {id: $nodeId, projectId: $projectId}) RETURN n.homeTabId AS h`, + { projectId, nodeId }, + ); + return r.records.length ? (r.records[0].get("h") ?? null) : null; + } + + async create(tab: StoredTab): Promise { + await this.neo4j.run( + `CREATE (t:Tab { + id: $id, projectId: $projectId, name: $name, isDefault: $isDefault, + order: $order, moduleNodeId: $moduleNodeId, + createdAt: datetime($createdAt), updatedAt: datetime($updatedAt) + })`, + { ...tab, moduleNodeId: tab.moduleNodeId ?? null }, + ); + } + + async list(projectId: string): Promise { + const r = await this.neo4j.run( + `MATCH (t:Tab {projectId: $projectId}) RETURN t ORDER BY t.order ASC`, + { projectId }, + ); + return r.records.map((rec) => toStoredTab(rec.get("t").properties)); + } + + async getById(projectId: string, tabId: string): Promise { + const r = await this.neo4j.run( + `MATCH (t:Tab {id: $tabId, projectId: $projectId}) RETURN t`, + { projectId, tabId }, + ); + return r.records.length ? toStoredTab(r.records[0].get("t").properties) : null; + } + + async findDefault(projectId: string): Promise { + const r = await this.neo4j.run( + `MATCH (t:Tab {projectId: $projectId, isDefault: true}) RETURN t LIMIT 1`, + { projectId }, + ); + return r.records.length ? toStoredTab(r.records[0].get("t").properties) : null; + } + + async maxOrder(projectId: string): Promise { + const r = await this.neo4j.run( + `MATCH (t:Tab {projectId: $projectId}) RETURN coalesce(max(t.order), -1) AS m`, + { projectId }, + ); + return Number(r.records[0].get("m")); + } + + async update( + projectId: string, + tabId: string, + patch: { name?: string; order?: number; updatedAt: string }, + ): Promise { + const sets: string[] = ["t.updatedAt = datetime($updatedAt)"]; + if (patch.name !== undefined) sets.push("t.name = $name"); + if (patch.order !== undefined) sets.push("t.order = $order"); + const r = await this.neo4j.run( + `MATCH (t:Tab {id: $tabId, projectId: $projectId}) SET ${sets.join(", ")} RETURN t`, + { projectId, tabId, name: patch.name ?? null, order: patch.order ?? null, updatedAt: patch.updatedAt }, + ); + return r.records.length ? toStoredTab(r.records[0].get("t").properties) : null; + } + + /** Delete tab: move owned nodes' home to default + delete tab (and REFERENCES). + * **Single atomic query** — previously 3 separate transactions; crash mid-way + * left partial state (moved nodes but undeleted tab / dangling REFERENCES). + * `DETACH DELETE` cleans all tab relationships (including REFERENCES); `count(n)` + * collapses rows to one (otherwise DELETE repeats per owned node). */ + async deleteAndReassign(projectId: string, tabId: string, defaultTabId: string): Promise { + await this.neo4j.run( + `OPTIONAL MATCH (n:Node {projectId: $projectId, homeTabId: $tabId}) + SET n.homeTabId = $defaultTabId + WITH count(n) AS reassigned + MATCH (t:Tab {id: $tabId, projectId: $projectId}) + DETACH DELETE t`, + { projectId, tabId, defaultTabId }, + ); + } + + /** Add/update reference (upsert). */ + async upsertReference(projectId: string, tabId: string, nodeId: string, x: number, y: number): Promise { + await this.neo4j.run( + `MATCH (t:Tab {id: $tabId, projectId: $projectId}) + MATCH (n:Node {id: $nodeId, projectId: $projectId}) + MERGE (t)-[r:REFERENCES]->(n) + SET r.x = $x, r.y = $y`, + { projectId, tabId, nodeId, x, y }, + ); + } + + async removeReference(projectId: string, tabId: string, nodeId: string): Promise { + const r = await this.neo4j.run( + `MATCH (t:Tab {id: $tabId, projectId: $projectId})-[r:REFERENCES]->(n:Node {id: $nodeId}) + DELETE r RETURN 1 AS d`, + { projectId, tabId, nodeId }, + ); + return r.records.length > 0; + } + + /** Toplu layout kaydet: owned → node.positionX/Y, referenced → REFERENCES.x/y. */ + async saveLayout(projectId: string, tabId: string, items: { nodeId: string; x: number; y: number }[]): Promise { + await this.neo4j.run( + `UNWIND $items AS item + MATCH (n:Node {id: item.nodeId, projectId: $projectId}) + FOREACH (_ IN CASE WHEN n.homeTabId = $tabId THEN [1] ELSE [] END | + SET n.positionX = item.x, n.positionY = item.y) + WITH n, item + OPTIONAL MATCH (t:Tab {id: $tabId, projectId: $projectId})-[r:REFERENCES]->(n) + FOREACH (_ IN CASE WHEN r IS NOT NULL THEN [1] ELSE [] END | + SET r.x = item.x, r.y = item.y)`, + { projectId, tabId, items }, + ); + } + + /** Tab render content: owned (homeTabId=tab) + referenced nodes + edges with both + * endpoints visible. */ + async tabGraph(projectId: string, tab: StoredTab): Promise { + const ownedRes = await this.neo4j.run( + `MATCH (n:Node {projectId: $projectId, homeTabId: $tabId}) RETURN n, labels(n) AS labels`, + { projectId, tabId: tab.id }, + ); + const owned: TabGraphMember[] = ownedRes.records.map((rec) => + memberFrom(rec.get("n").properties, rec.get("labels"), false), + ); + + const refRes = await this.neo4j.run( + `MATCH (:Tab {id: $tabId, projectId: $projectId})-[r:REFERENCES]->(n:Node) + RETURN n, labels(n) AS labels, r.x AS x, r.y AS y`, + { projectId, tabId: tab.id }, + ); + const referenced: TabGraphMember[] = refRes.records.map((rec) => { + const m = memberFrom(rec.get("n").properties, rec.get("labels"), true); + m.position = { x: Number(rec.get("x")), y: Number(rec.get("y")) }; + return m; + }); + + const members = [...owned, ...referenced]; + const visibleIds = members.map((m) => m.id); + + const edgesRes = await this.neo4j.run( + `MATCH (s:Node)-[e]->(t:Node) + WHERE e.projectId = $projectId AND s.id IN $ids AND t.id IN $ids + RETURN e.id AS id, type(e) AS kind, s.id AS sourceNodeId, t.id AS targetNodeId`, + { projectId, ids: visibleIds }, + ); + const edges: TabGraphEdge[] = edgesRes.records.map((rec) => ({ + id: rec.get("id"), + kind: rec.get("kind"), + sourceNodeId: rec.get("sourceNodeId"), + targetNodeId: rec.get("targetNodeId"), + })); + + return { tab, nodes: members, edges }; + } +} + +function toStoredTab(p: any): StoredTab { + return { + id: p.id, + projectId: p.projectId, + name: p.name, + isDefault: p.isDefault, + order: Number(p.order), + moduleNodeId: p.moduleNodeId ?? undefined, + createdAt: new Date(p.createdAt).toISOString(), + updatedAt: new Date(p.updatedAt).toISOString(), + }; +} + +function memberFrom(p: any, labels: string[], isReference: boolean): TabGraphMember { + const kind = labels.find((l: string) => l !== "Node") as string; + return { + id: p.id, + type: kind, + properties: JSON.parse(p.properties), + position: { x: Number(p.positionX), y: Number(p.positionY) }, + version: Number(p.version ?? 1), + isReference, + origin: isReference ? p.homeTabId : undefined, + // Implementation counters — fields omitted when never reported. + ...(p.implTotal != null + ? { + implTotal: Number(p.implTotal), + implFilled: Number(p.implFilled ?? 0), + implAi: Number(p.implAi ?? 0), + } + : {}), + }; +} diff --git a/apps/server/src/tabs/tabs.service.spec.ts b/apps/server/src/tabs/tabs.service.spec.ts new file mode 100644 index 0000000..ba70435 --- /dev/null +++ b/apps/server/src/tabs/tabs.service.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from "vitest"; +import { TabsService } from "./tabs.service"; + +function make() { + const repo = { + findDefault: vi.fn(), create: vi.fn(), list: vi.fn(), getById: vi.fn(), + update: vi.fn(), deleteAndReassign: vi.fn(), maxOrder: vi.fn().mockResolvedValue(0), + projectExists: vi.fn().mockResolvedValue(true), nodeExists: vi.fn().mockResolvedValue(true), + upsertReference: vi.fn(), removeReference: vi.fn(), nodeHomeTab: vi.fn(), tabGraph: vi.fn(), + }; + return { svc: new TabsService(repo as any), repo }; +} + +describe("TabsService", () => { + it("ensureDefault does not create when one exists", async () => { + const { svc, repo } = make(); + repo.findDefault.mockResolvedValue({ id: "d", isDefault: true }); + await svc.ensureDefault("p"); + expect(repo.create).not.toHaveBeenCalled(); + }); + + it("ensureDefault creates Main Architecture when missing", async () => { + const { svc, repo } = make(); + repo.findDefault.mockResolvedValue(null); + const t = await svc.ensureDefault("p"); + expect(t.name).toBe("Main Architecture"); + expect(t.isDefault).toBe(true); + expect(repo.create).toHaveBeenCalled(); + }); + + it("default tab cannot be deleted", async () => { + const { svc, repo } = make(); + repo.getById.mockResolvedValue({ id: "d", isDefault: true }); + await expect(svc.delete("p", "d")).rejects.toThrow(); + }); + + it("node cannot reference its own home tab", async () => { + const { svc, repo } = make(); + repo.getById.mockResolvedValue({ id: "t1", isDefault: false }); + repo.nodeHomeTab.mockResolvedValue("t1"); + await expect(svc.addReference("p", "t1", "n", 0, 0)).rejects.toThrow(); + }); + + it("addReference upserts on a different home tab", async () => { + const { svc, repo } = make(); + repo.getById.mockResolvedValue({ id: "t2", isDefault: false }); + repo.nodeHomeTab.mockResolvedValue("t1"); + await svc.addReference("p", "t2", "n", 5, 6); + expect(repo.upsertReference).toHaveBeenCalledWith("p", "t2", "n", 5, 6); + }); +}); diff --git a/apps/server/src/tabs/tabs.service.ts b/apps/server/src/tabs/tabs.service.ts new file mode 100644 index 0000000..76fe924 --- /dev/null +++ b/apps/server/src/tabs/tabs.service.ts @@ -0,0 +1,108 @@ +import { Injectable, BadRequestException, NotFoundException } from "@nestjs/common"; +import { randomUUID } from "node:crypto"; +import { TabsRepository } from "./tabs.repository"; +import type { StoredTab, TabGraph, CreateTabInput, UpdateTabInput } from "./schemas/tab.schema"; + +@Injectable() +export class TabsService { + constructor(private readonly repo: TabsRepository) {} + + /** Project default ("Main Architecture") tab — creates if missing (idempotent). */ + async ensureDefault(projectId: string): Promise { + const existing = await this.repo.findDefault(projectId); + if (existing) return existing; + const now = new Date().toISOString(); + const tab: StoredTab = { + id: randomUUID(), projectId, name: "Main Architecture", + isDefault: true, order: 0, createdAt: now, updatedAt: now, + }; + await this.repo.create(tab); + return tab; + } + + async create(projectId: string, input: CreateTabInput): Promise { + await this.assertProject(projectId); + const order = (await this.repo.maxOrder(projectId)) + 1; + const now = new Date().toISOString(); + const tab: StoredTab = { + id: randomUUID(), projectId, name: input.name, + isDefault: false, order, moduleNodeId: input.moduleNodeId, + createdAt: now, updatedAt: now, + }; + await this.repo.create(tab); + return tab; + } + + async list(projectId: string): Promise { + await this.assertProject(projectId); + return this.repo.list(projectId); + } + + async getById(projectId: string, tabId: string): Promise { + const tab = await this.repo.getById(projectId, tabId); + if (!tab) throw this.tabNotFound(tabId); + return tab; + } + + async update(projectId: string, tabId: string, input: UpdateTabInput): Promise { + const updated = await this.repo.update(projectId, tabId, { ...input, updatedAt: new Date().toISOString() }); + if (!updated) throw this.tabNotFound(tabId); + return updated; + } + + async delete(projectId: string, tabId: string): Promise { + const tab = await this.getById(projectId, tabId); + if (tab.isDefault) { + throw new BadRequestException({ + code: "ERR_TAB_DEFAULT_DELETE", + message: "The default 'Main Architecture' tab cannot be deleted.", + }); + } + const def = await this.repo.findDefault(projectId); + if (!def) throw this.tabNotFound("default"); + await this.repo.deleteAndReassign(projectId, tabId, def.id); + } + + async tabGraph(projectId: string, tabId: string): Promise { + const tab = await this.getById(projectId, tabId); + return this.repo.tabGraph(projectId, tab); + } + + async addReference(projectId: string, tabId: string, nodeId: string, x: number, y: number): Promise { + const tab = await this.getById(projectId, tabId); + if (!(await this.repo.nodeExists(projectId, nodeId))) { + throw new NotFoundException({ code: "ERR_NODE_NOT_FOUND", message: `Node '${nodeId}' not found.` }); + } + // Adding a node as reference on its own home tab is meaningless. + const homeTabId = await this.repo.nodeHomeTab(projectId, nodeId); + if (homeTabId === tab.id) { + throw new BadRequestException({ + code: "ERR_TAB_SELF_REFERENCE", + message: "Node already owns this tab; a reference cannot be added.", + }); + } + await this.repo.upsertReference(projectId, tab.id, nodeId, x, y); + } + + async removeReference(projectId: string, tabId: string, nodeId: string): Promise { + await this.getById(projectId, tabId); + if (!(await this.repo.removeReference(projectId, tabId, nodeId))) { + throw new NotFoundException({ code: "ERR_REFERENCE_NOT_FOUND", message: `Reference not found.` }); + } + } + + async saveLayout(projectId: string, tabId: string, items: { nodeId: string; x: number; y: number }[]): Promise { + await this.getById(projectId, tabId); + await this.repo.saveLayout(projectId, tabId, items); + } + + private async assertProject(projectId: string): Promise { + if (!(await this.repo.projectExists(projectId))) { + throw new NotFoundException({ code: "ERR_PROJECT_NOT_FOUND", message: `Project '${projectId}' not found.` }); + } + } + + private tabNotFound(tabId: string): NotFoundException { + return new NotFoundException({ code: "ERR_TAB_NOT_FOUND", message: `Tab '${tabId}' not found.` }); + } +} diff --git a/apps/server/src/value-sets/registry.ts b/apps/server/src/value-sets/registry.ts new file mode 100644 index 0000000..7d74c55 --- /dev/null +++ b/apps/server/src/value-sets/registry.ts @@ -0,0 +1,271 @@ +/** Solarch value-set registry — static enum/lookup catalog. + * Shared domain enums across all node type properties. + * Referenced via fieldHint.valueSet; frontend uses Select widget. */ + +export interface ValueOption { + /** Canonical machine value — stored in DB/JSON. */ + value: string; + /** Display label — falls back to value if omitted. */ + label?: string; + /** Tooltip / description. */ + description?: string; + /** Subgroup (e.g. primitives, collections, async). */ + group?: string; +} + +export interface ValueSet { + id: string; + label: string; + description: string; + values: ValueOption[]; +} + +export const VALUE_SETS: Record = { + // ── Basic types ─────────────────────────────────────────────── + "primitive-types": { + id: "primitive-types", + label: "Primitive Types", + description: "TypeScript / language-agnostic primitive data types.", + values: [ + { value: "string", description: "Text" }, + { value: "number", description: "Number (float)" }, + { value: "integer", description: "Integer" }, + { value: "boolean", description: "true / false" }, + { value: "Date", description: "Date + time" }, + { value: "UUID", description: "Universally unique identifier" }, + { value: "any", description: "Type unknown (not recommended)" }, + { value: "void", description: "No return value" }, + { value: "null", description: "Empty value" }, + ], + }, + + // ── Parameter / Return tipi (primitives + collection + async) ── + "parameter-types": { + id: "parameter-types", + label: "Parameter / Return Types", + description: "Commonly used types for method parameters and return values.", + values: [ + // primitives + { value: "string", group: "primitive" }, + { value: "number", group: "primitive" }, + { value: "integer", group: "primitive" }, + { value: "boolean", group: "primitive" }, + { value: "Date", group: "primitive" }, + { value: "UUID", group: "primitive" }, + { value: "void", group: "primitive" }, + { value: "any", group: "primitive" }, + // collections + { value: "string[]", group: "collection" }, + { value: "number[]", group: "collection" }, + { value: "any[]", group: "collection" }, + { value: "Record", group: "collection" }, + { value: "Map", group: "collection" }, + // async + { value: "Promise", group: "async" }, + { value: "Promise", group: "async" }, + { value: "Promise", group: "async" }, + { value: "Observable", group: "async" }, + ], + }, + + // ── OO visibility ────────────────────────────────────────────── + visibility: { + id: "visibility", + label: "Visibility (Access Modifier)", + description: "Method/property access level.", + values: [ + { value: "public", description: "Accessible from outside" }, + { value: "private", description: "Class-internal only" }, + { value: "protected", description: "Class + subclasses" }, + ], + }, + + // ── HTTP ────────────────────────────────────────────────────── + "http-methods": { + id: "http-methods", + label: "HTTP Methods", + description: "REST endpoint HTTP verbs.", + values: [ + { value: "GET", description: "Read a resource (idempotent)" }, + { value: "POST", description: "Create a resource" }, + { value: "PUT", description: "Full update (idempotent)" }, + { value: "PATCH", description: "Partial update" }, + { value: "DELETE", description: "Delete a resource (idempotent)" }, + { value: "OPTIONS", description: "CORS preflight, capability discovery" }, + { value: "HEAD", description: "Headers only (no body)" }, + ], + }, + + "http-status": { + id: "http-status", + label: "HTTP Status Codes", + description: "Commonly used HTTP status codes.", + values: [ + { value: "200", label: "200 OK", group: "success" }, + { value: "201", label: "201 Created", group: "success" }, + { value: "202", label: "202 Accepted", group: "success" }, + { value: "204", label: "204 No Content", group: "success" }, + { value: "301", label: "301 Moved Permanently", group: "redirect" }, + { value: "302", label: "302 Found", group: "redirect" }, + { value: "304", label: "304 Not Modified", group: "redirect" }, + { value: "400", label: "400 Bad Request", group: "client-error" }, + { value: "401", label: "401 Unauthorized", group: "client-error" }, + { value: "403", label: "403 Forbidden", group: "client-error" }, + { value: "404", label: "404 Not Found", group: "client-error" }, + { value: "409", label: "409 Conflict", group: "client-error" }, + { value: "422", label: "422 Unprocessable Entity", group: "client-error" }, + { value: "429", label: "429 Too Many Requests", group: "client-error" }, + { value: "500", label: "500 Internal Server Error", group: "server-error" }, + { value: "502", label: "502 Bad Gateway", group: "server-error" }, + { value: "503", label: "503 Service Unavailable", group: "server-error" }, + ], + }, + + // ── Validation rules ────────────────────────────────────────── + "validation-rules": { + id: "validation-rules", + label: "Validation Rules", + description: "DTO field validation rule names.", + values: [ + { value: "Min", description: "Numeric lower bound" }, + { value: "Max", description: "Numeric upper bound" }, + { value: "MinLength", description: "String / array minimum length" }, + { value: "MaxLength", description: "String / array maximum length" }, + { value: "Email", description: "Valid email format" }, + { value: "Url", description: "Valid URL format" }, + { value: "Regex", description: "Regex pattern match" }, + { value: "Pattern", description: "General pattern (Regex alias)" }, + { value: "Positive", description: "Number greater than zero" }, + { value: "Negative", description: "Number less than zero" }, + { value: "Required", description: "Cannot be empty" }, + { value: "Optional", description: "Can be left empty" }, + ], + }, + + // ── DB column data types ────────────────────────────────────── + "column-data-types": { + id: "column-data-types", + label: "Column Data Types", + description: "SQL DDL column types (common PostgreSQL/MySQL set).", + values: [ + { value: "INT", group: "numeric" }, + { value: "BIGINT", group: "numeric" }, + { value: "FLOAT", group: "numeric" }, + { value: "DECIMAL", description: "With Precision + Scale", group: "numeric" }, + { value: "VARCHAR", description: "Length parameter required", group: "text" }, + { value: "TEXT", description: "Unlimited length", group: "text" }, + { value: "BOOLEAN", group: "boolean" }, + { value: "DATE", group: "date" }, + { value: "DATETIME", description: "Date + time", group: "date" }, + { value: "UUID", group: "identifier" }, + { value: "JSON", description: "JSON / JSONB", group: "structured" }, + { value: "ENUM", description: "Reference via EnumRef required", group: "structured" }, + ], + }, + + // ── ORM relations ───────────────────────────────────────────── + "relation-types": { + id: "relation-types", + label: "Relation Types", + description: "ORM relationship cardinality.", + values: [ + { value: "OneToOne", description: "1:1 — User ↔ Profile" }, + { value: "OneToMany", description: "1:N — User ↔ Orders" }, + { value: "ManyToOne", description: "N:1 — Orders → User" }, + { value: "ManyToMany", description: "N:N — Students ↔ Courses (junction table)" }, + ], + }, + + // ── Foreign key actions ─────────────────────────────────────── + "on-delete-actions": { + id: "on-delete-actions", + label: "ON DELETE / UPDATE Actions", + description: "Foreign key referential integrity actions.", + values: [ + { value: "CASCADE", description: "When the parent is deleted, the child is deleted too" }, + { value: "RESTRICT", description: "Prevent deletion if a child exists" }, + { value: "SET_NULL", description: "The FK on the child is set to NULL (column must be nullable)" }, + { value: "NO_ACTION", description: "DB-level default (usually RESTRICT)" }, + ], + }, + + // ── Protocols ───────────────────────────────────────────────── + protocols: { + id: "protocols", + label: "Communication Protocols", + description: "Inter-service communication protocols.", + values: [ + { value: "HTTP", description: "Over REST / GraphQL" }, + { value: "gRPC", description: "Protocol Buffers + HTTP/2" }, + { value: "TCP", description: "Low-level stream" }, + { value: "WebSocket", description: "Bidirectional persistent" }, + { value: "AMQP", description: "RabbitMQ messaging" }, + { value: "MQTT", description: "IoT / pub-sub" }, + ], + }, + + // ── Service dependency kinds ───────────────────────────────── + "service-dep-kinds": { + id: "service-dep-kinds", + label: "Service Dependency Kinds", + description: "The injected dependency type of a Service.", + values: [ + { value: "Repository", description: "Persistence layer (DB)" }, + { value: "Service", description: "Another bounded-context service" }, + { value: "Cache", description: "Redis / Memcached etc." }, + { value: "ExternalService", description: "Stripe, SendGrid, OpenAI etc." }, + ], + }, + + // ── Middleware types ───────────────────────────────────────── + "middleware-types": { + id: "middleware-types", + label: "Middleware Types", + description: "Pipeline middleware category.", + values: [ + { value: "Auth", description: "JWT / session validation" }, + { value: "Logging", description: "Request logging" }, + { value: "RateLimit", description: "Per-IP / per-user request counter" }, + { value: "Cors", description: "CORS headers" }, + { value: "Compression", description: "gzip / brotli response" }, + { value: "ErrorHandler", description: "Global exception → JSON" }, + { value: "Custom", description: "Other" }, + ], + }, + + // ── Middleware applies-to scope ────────────────────────────── + "middleware-scope": { + id: "middleware-scope", + label: "Middleware Scope", + description: "The scope the middleware is applied to.", + values: [ + { value: "Global", description: "On all routes" }, + { value: "SpecificRoutes", description: "Only on specific endpoints" }, + ], + }, + + // ── Enum backing type ──────────────────────────────────────── + "enum-backing-types": { + id: "enum-backing-types", + label: "Enum Backing Types", + description: "Storage type of enum values.", + values: [ + { value: "string", description: "String backing (recommended)" }, + { value: "int", description: "Integer backing (legacy)" }, + ], + }, + + // ── View refresh strategy ──────────────────────────────────── + "view-refresh-strategy": { + id: "view-refresh-strategy", + label: "View Refresh Strategy", + description: "Materialized view refresh trigger.", + values: [ + { value: "onDemand", description: "Via a manual REFRESH command" }, + { value: "scheduled", description: "Cron schedule" }, + { value: "onChange", description: "On change in the source tables" }, + ], + }, +}; + +export const VALUE_SET_IDS = Object.keys(VALUE_SETS); diff --git a/apps/server/src/value-sets/value-sets.controller.ts b/apps/server/src/value-sets/value-sets.controller.ts new file mode 100644 index 0000000..32b6f79 --- /dev/null +++ b/apps/server/src/value-sets/value-sets.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Param } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiParam, ApiResponse } from "@nestjs/swagger"; +import { ValueSetsService } from "./value-sets.service"; +import { ok } from "../common/envelope"; + +@ApiTags("Value Sets") +@Controller("value-sets") +export class ValueSetsController { + constructor(private readonly service: ValueSetsService) {} + + @Get() + @ApiOperation({ + summary: "List of all value-sets", + description: + "Solarch's shared enum / lookup catalog. Common value sets used in each node type's properties " + + "(parameter-types, http-methods, column-data-types, etc.). " + + "Referenced via fieldHint.valueSet.", + }) + @ApiResponse({ status: 200, description: "Array of value-set summaries" }) + list() { + return ok(this.service.list()); + } + + @Get(":id") + @ApiOperation({ + summary: "Single value-set (with all values)", + description: "Used by the frontend Select widget.", + }) + @ApiParam({ name: "id", description: "Value-set id (e.g. 'parameter-types', 'http-methods')" }) + @ApiResponse({ status: 200, description: "Value-set with all values" }) + @ApiResponse({ status: 404, description: "ERR_VALUE_SET_NOT_FOUND" }) + getById(@Param("id") id: string) { + return ok(this.service.getById(id)); + } +} diff --git a/apps/server/src/value-sets/value-sets.module.ts b/apps/server/src/value-sets/value-sets.module.ts new file mode 100644 index 0000000..d54059a --- /dev/null +++ b/apps/server/src/value-sets/value-sets.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { ValueSetsController } from "./value-sets.controller"; +import { ValueSetsService } from "./value-sets.service"; + +@Module({ + controllers: [ValueSetsController], + providers: [ValueSetsService], + exports: [ValueSetsService], +}) +export class ValueSetsModule {} diff --git a/apps/server/src/value-sets/value-sets.service.ts b/apps/server/src/value-sets/value-sets.service.ts new file mode 100644 index 0000000..8fff55c --- /dev/null +++ b/apps/server/src/value-sets/value-sets.service.ts @@ -0,0 +1,35 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { VALUE_SETS, type ValueSet } from "./registry"; + +export interface ValueSetSummary { + id: string; + label: string; + description: string; + count: number; +} + +@Injectable() +export class ValueSetsService { + /** Summary of all value sets (id + label + count). */ + list(): { sets: ValueSetSummary[]; total: number } { + const sets = Object.values(VALUE_SETS).map((v) => ({ + id: v.id, + label: v.label, + description: v.description, + count: v.values.length, + })); + return { sets, total: sets.length }; + } + + /** Single value set with all values. */ + getById(id: string): ValueSet { + const set = VALUE_SETS[id]; + if (!set) { + throw new NotFoundException({ + code: "ERR_VALUE_SET_NOT_FOUND", + message: `Value set '${id}' not found.`, + }); + } + return set; + } +} diff --git a/apps/server/test/auth.e2e-spec.ts b/apps/server/test/auth.e2e-spec.ts new file mode 100644 index 0000000..0c18f84 --- /dev/null +++ b/apps/server/test/auth.e2e-spec.ts @@ -0,0 +1,129 @@ +import "reflect-metadata"; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { Test } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import { ZodValidationPipe as ZodPipe } from "nestjs-zod"; +import express from "express"; +import request from "supertest"; +import { Neo4jContainer, StartedNeo4jContainer } from "@testcontainers/neo4j"; +import { AppModule } from "../src/app.module"; +import { Neo4jService } from "../src/neo4j/neo4j.service"; +import { LocalAuthGuard } from "../src/auth/local-auth.guard"; +import { SchemaErrorFilter } from "../src/common/filters/schema-error.filter"; +import { NotFoundFilter } from "../src/common/filters/not-found.filter"; +import { ConflictFilter } from "../src/common/filters/conflict.filter"; +import { InternalFilter } from "../src/common/filters/internal.filter"; +import { UnauthorizedFilter } from "../src/common/filters/unauthorized.filter"; +import { ForbiddenFilter } from "../src/common/filters/forbidden.filter"; +import { headerAuthGuardValue } from "./test-auth"; + +/** Authentication + multi-tenancy (BOLA) e2e. +* With the LocalAuthGuard header-stub (x-test-user) two users are simulated; +* ProjectAccessGuard works REAL — it tests the actual BOLA protection. */ +describe("Auth + Tenancy E2E", () => { + let container: StartedNeo4jContainer; + let app: INestApplication; + let neo4j: Neo4jService; + const base = "/api/v1"; + const USER_A = "user_A"; + const USER_B = "user_B"; + + beforeAll(async () => { + container = await new Neo4jContainer("neo4j:5-community").withApoc().start(); + neo4j = new Neo4jService({ + uri: container.getBoltUri(), + user: container.getUsername(), + password: container.getPassword(), + }); + await neo4j.onModuleInit(); + await neo4j.run("CREATE CONSTRAINT node_id_unique IF NOT EXISTS FOR (n:Node) REQUIRE n.id IS UNIQUE"); + await neo4j.run("CREATE CONSTRAINT project_id_unique IF NOT EXISTS FOR (p:Project) REQUIRE p.id IS UNIQUE"); + await neo4j.run("CREATE CONSTRAINT tab_id_unique IF NOT EXISTS FOR (t:Tab) REQUIRE t.id IS UNIQUE"); + + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(Neo4jService) + .useValue(neo4j) + .overrideProvider(LocalAuthGuard) + .useValue(headerAuthGuardValue()) + .compile(); + + app = moduleRef.createNestApplication(); + app.use(express.json()); + app.useGlobalPipes(new ZodPipe()); + app.setGlobalPrefix("api/v1"); + app.useGlobalFilters( + new InternalFilter(), + new UnauthorizedFilter(), + new ForbiddenFilter(), + new ConflictFilter(), + new NotFoundFilter(), + new SchemaErrorFilter(), + ); + await app.init(); + }, 180_000); + + afterAll(async () => { + await app?.close(); + await neo4j?.onModuleDestroy(); + await container?.stop(); + }); + + beforeEach(async () => { + await neo4j.run("MATCH (n) DETACH DELETE n"); + }); + + const createProject = (user: string, name: string) => + request(app.getHttpServer()) + .post(`${base}/projects`) + .set("x-test-user", user) + .send({ name, description: "", status: "draft" }); + + it("returns 401 ERR_UNAUTHORIZED when identity is missing", async () => { + const res = await request(app.getHttpServer()).get(`${base}/projects`).expect(401); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe("ERR_UNAUTHORIZED"); + }); + + it("create stamps ownerId on project", async () => { + const res = await createProject(USER_A, "User A project").expect(201); + expect(res.body.data.ownerId).toBe(USER_A); + expect(res.body.data.orgId).toBeNull(); + }); + + it("list returns only caller's projects", async () => { + await createProject(USER_A, "A1").expect(201); + await createProject(USER_B, "B1").expect(201); + + const listA = await request(app.getHttpServer()) + .get(`${base}/projects`).set("x-test-user", USER_A).expect(200); + const namesA = listA.body.data.projects.map((p: { name: string }) => p.name); + expect(namesA).toContain("A1"); + expect(namesA).not.toContain("B1"); + }); + + it("other user cannot access project → 403 ERR_PROJECT_FORBIDDEN", async () => { + const created = await createProject(USER_A, "A2").expect(201); + const projectId = created.body.data.id; + +// can access A + await request(app.getHttpServer()) + .get(`${base}/projects/${projectId}`).set("x-test-user", USER_A).expect(200); + +// B cannot access the sub-resource (ProjectAccessGuard) + const denied = await request(app.getHttpServer()) + .get(`${base}/projects/${projectId}/nodes`).set("x-test-user", USER_B).expect(403); + expect(denied.body.error.code).toBe("ERR_PROJECT_FORBIDDEN"); + +// B cannot see the individual project either (service assertAccess) + const deniedGet = await request(app.getHttpServer()) + .get(`${base}/projects/${projectId}`).set("x-test-user", USER_B).expect(403); + expect(deniedGet.body.error.code).toBe("ERR_PROJECT_FORBIDDEN"); + }); + + it("owner can access own project node list → 200", async () => { + const created = await createProject(USER_A, "A3").expect(201); + const projectId = created.body.data.id; + await request(app.getHttpServer()) + .get(`${base}/projects/${projectId}/nodes`).set("x-test-user", USER_A).expect(200); + }); +}); diff --git a/apps/server/test/codegen.e2e-spec.ts b/apps/server/test/codegen.e2e-spec.ts new file mode 100644 index 0000000..35f2e6c --- /dev/null +++ b/apps/server/test/codegen.e2e-spec.ts @@ -0,0 +1,346 @@ +import "reflect-metadata"; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { Test } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import { ZodValidationPipe as ZodPipe } from "nestjs-zod"; +import request from "supertest"; +import { Neo4jContainer, StartedNeo4jContainer } from "@testcontainers/neo4j"; +import { AppModule } from "../src/app.module"; +import { Neo4jService } from "../src/neo4j/neo4j.service"; +import { SchemaErrorFilter } from "../src/common/filters/schema-error.filter"; +import { NotFoundFilter } from "../src/common/filters/not-found.filter"; +import { ConflictFilter } from "../src/common/filters/conflict.filter"; +import { InternalFilter } from "../src/common/filters/internal.filter"; +import { bypassAuth, TEST_AUTH } from "./test-auth"; + +/* ──────────────────────────────────────────────────────────────────────── +* codegen.e2e-spec.ts — POST /projects/:id/codegen end-to-end. + * + * - POST /projects/:id/codegen -> 200 + data.{ target, files[], summary }. + * + * Fixture: Controller -CALLS-> Service -CALLS-> Repository -WRITES-> Table +* + DTO + Enum. Nodes are seeded with the real API (passed through Zod verification), +* codegen pulls from DB and produces it. + * ──────────────────────────────────────────────────────────────────────── */ + +const projectId = "550e8400-e29b-41d4-a716-446655440099"; +const base = "/api/v1"; + +describe("Codegen E2E (POST /projects/:id/codegen)", () => { + let container: StartedNeo4jContainer; + let app: INestApplication; + let neo4j: Neo4jService; + + beforeAll(async () => { + container = await new Neo4jContainer("neo4j:5-community").withApoc().start(); + neo4j = new Neo4jService({ + uri: container.getBoltUri(), + user: container.getUsername(), + password: container.getPassword(), + }); + await neo4j.onModuleInit(); + await neo4j.run("CREATE CONSTRAINT node_id_unique IF NOT EXISTS FOR (n:Node) REQUIRE n.id IS UNIQUE"); + await neo4j.run("CREATE CONSTRAINT project_id_unique IF NOT EXISTS FOR (p:Project) REQUIRE p.id IS UNIQUE"); + await neo4j.run("CREATE INDEX node_project_idx IF NOT EXISTS FOR (n:Node) ON (n.projectId)"); + + const moduleRef = await bypassAuth( + Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(Neo4jService) + .useValue(neo4j), + ).compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes(new ZodPipe()); + app.setGlobalPrefix("api/v1"); + app.useGlobalFilters( + new InternalFilter(), + new ConflictFilter(), + new NotFoundFilter(), + new SchemaErrorFilter(), + ); + await app.init(); + }, 180_000); + + afterAll(async () => { + await app?.close(); + await neo4j?.onModuleDestroy(); + await container?.stop(); + }); + + beforeEach(async () => { + await neo4j.run("MATCH (n) DETACH DELETE n"); + await neo4j.run( + `CREATE (p:Project {id: $id, name: 'Codegen E2E', description: 'test', status: 'draft', + ownerId: $uid, orgId: null, createdAt: datetime(), updatedAt: datetime()})`, + { id: projectId, uid: TEST_AUTH.userId }, + ); + }); + +/** Creates the node from the real API (passes Zod validation), returns the id. */ + async function createNode(payload: object): Promise { + const res = await request(app.getHttpServer()) + .post(`${base}/projects/${projectId}/nodes`) + .send(payload) + .expect(201); + return res.body.data.id; + } + + async function createEdge(sourceNodeId: string, targetNodeId: string, kind: string): Promise { + await request(app.getHttpServer()) + .post(`${base}/projects/${projectId}/edges`) + .send({ projectId, sourceNodeId, targetNodeId, kind, properties: { IsAsync: false } }) + .expect(201); + } + + /** Controller -CALLS-> Service -CALLS-> Repository -WRITES-> Table + DTO + Enum. */ + async function seedGraph(): Promise { + const table = await createNode({ + type: "Table", + projectId, + position: { x: 0, y: 0 }, + properties: { + TableName: "users", + Description: "u", + Columns: [ + { Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }, + { Name: "email", DataType: "VARCHAR", Length: 255, IsPrimaryKey: false, IsNotNull: true, IsUnique: true, AutoIncrement: false }, + ], + }, + }); + await createNode({ + type: "Enum", + projectId, + position: { x: 0, y: 0 }, + properties: { Name: "UserRole", Description: "e", BackingType: "string", Values: [{ Key: "ADMIN" }, { Key: "MEMBER" }] }, + }); + const dto = await createNode({ + type: "DTO", + projectId, + position: { x: 0, y: 0 }, + properties: { + Name: "CreateUserDto", + Description: "d", + Fields: [{ Name: "email", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [{ Rule: "Email" }] }], + }, + }); + const repo = await createNode({ + type: "Repository", + projectId, + position: { x: 0, y: 0 }, + properties: { + RepositoryName: "UserRepository", + Description: "r", + EntityReference: "users", + CustomQueries: [{ QueryName: "findByEmail", QueryType: "findOne", Parameters: [{ Name: "email", Type: "string" }], ReturnType: "User" }], + }, + }); + const service = await createNode({ + type: "Service", + projectId, + position: { x: 0, y: 0 }, + properties: { + ServiceName: "UsersService", + Description: "s", + IsTransactionScoped: true, + Methods: [ + { + MethodName: "create", + Parameters: [{ Name: "dto", Type: "CreateUserDto", DtoRef: "CreateUserDto" }], + ReturnType: "void", + IsAsync: true, + }, + ], + Dependencies: [{ Kind: "Repository", Ref: "UserRepository" }], + }, + }); + const controller = await createNode({ + type: "Controller", + projectId, + position: { x: 0, y: 0 }, + properties: { + ControllerName: "UsersController", + Description: "c", + BaseRoute: "users", + Version: "v1", + Endpoints: [ + { + HttpMethod: "POST", + Route: "/", + RequestDTORef: "CreateUserDto", + RequiresAuth: true, + StatusCodes: [{ Code: 201 }], + }, + ], + }, + }); + +// CRITICAL: Controller->Service only from CALLS edge. + await createEdge(controller, service, "CALLS"); + await createEdge(service, repo, "CALLS"); + await createEdge(repo, table, "WRITES"); + } + + it("POST codegen -> 200 + data.{ target, files[], summary }", async () => { + await seedGraph(); + + const res = await request(app.getHttpServer()) + .post(`${base}/projects/${projectId}/codegen`) + .send({ target: "nestjs" }) + .expect(200); + + expect(res.body.success).toBe(true); + const data = res.body.data; + expect(data.target).toBe("nestjs"); + expect(Array.isArray(data.files)).toBe(true); + expect(data.files.length).toBeGreaterThan(0); + +// Verify that core files have been generated (ARCHITECTURE-AWARE feature layout). + const paths: string[] = data.files.map((f: { path: string }) => f.path); +// Idiomatic names: role suffix is ​​NOT repeated in filename + feature folder. + expect(paths).toContain("src/users/users.controller.ts"); + expect(paths).toContain("src/users/users.service.ts"); + expect(paths).toContain("src/users/user.repository.ts"); + // Feature module SYNTHESIZED (even without Module node) -> DI complete. + expect(paths).toContain("src/users/users.module.ts"); + expect(paths.some((p) => p.endsWith(".sql"))).toBe(true); + expect(paths).toContain("package.json"); + expect(paths).toContain("src/app.module.ts"); +// Per-node parent folder NONE (old disjoint structure like "src/users-controller/..." is gone). + expect(paths.some((p) => /\/users-controller\//.test(p))).toBe(false); + expect(paths.some((p) => /\/create-user-dto\//.test(p))).toBe(false); + +// Summary filled correctly. + expect(data.summary.nodeCount).toBe(6); + expect(data.summary.fileCount).toBe(data.files.length); + expect(data.summary.surgicalMarkerCount).toBeGreaterThan(0); + +// Controller->Service DI came from CALLS edge. + const controller = data.files.find( + (f: { path: string }) => f.path === "src/users/users.controller.ts", + ); + expect(controller.content).toContain("UsersService"); + +// app.module does NOT raw-register controller/provider — imports feature module -> boots. + const appModule = data.files.find((f: { path: string }) => f.path === "src/app.module.ts"); + expect(appModule.content).toContain("UsersModule"); + expect(appModule.content).not.toContain("controllers:"); + +// Feature module providers have repository -> DI is full. + const featureModule = data.files.find( + (f: { path: string }) => f.path === "src/users/users.module.ts", + ); + expect(featureModule.content).toContain("UserRepository"); + expect(featureModule.content).toContain("UsersService"); + }); + +it("no project -> 404 ERR_PROJECT_NOT_FOUND", async () => { + const res = await request(app.getHttpServer()) + .post(`${base}/projects/11111111-1111-4111-8111-111111111111/codegen`) + .send({ target: "nestjs" }) + .expect(404); + expect(res.body.error.code).toBe("ERR_PROJECT_NOT_FOUND"); + }); + + it("skippedKinds + stub: unsupported kind (Cache) survives DB round-trip", async () => { + await seedGraph(); + +// Add an unsupported kind (Cache) — emitter produces stub, skippedKinds counts. + await createNode({ + type: "Cache", + projectId, + position: { x: 0, y: 0 }, + properties: { + CacheName: "SessionCache", + Description: "Session cache", + KeyPattern: "session:{id}", + TTL_Seconds: 3600, + Engine: "Redis", + EvictionPolicy: "LRU", + }, + }); + + const res = await request(app.getHttpServer()) + .post(`${base}/projects/${projectId}/codegen`) + .send({ target: "nestjs" }) + .expect(200); + + const data = res.body.data; +// skippedKinds Passed the full path Controller->Service->Repository->Neo4j. + expect(data.summary.skippedKinds).toEqual({ Cache: 1 }); +// The relevant stub file should be generated. + const paths: string[] = data.files.map((f: { path: string }) => f.path); + expect(paths.some((p) => p.endsWith(".cache.stub.ts"))).toBe(true); + }); + + it("DETERMINISM: same graph generated twice -> byte-identical files", async () => { + await seedGraph(); + + const a = await request(app.getHttpServer()) + .post(`${base}/projects/${projectId}/codegen`).send({ target: "nestjs" }).expect(200); + const b = await request(app.getHttpServer()) + .post(`${base}/projects/${projectId}/codegen`).send({ target: "nestjs" }).expect(200); + + expect(JSON.stringify(a.body.data.files)).toBe(JSON.stringify(b.body.data.files)); + }); + + // ── VERSION STAMPING + STATUS ──────────────────────────────────────────── +// GeneratedProject.summary.version carries the current Constructor version; successful +//generate stamps the project node; GET .../codegen/status CURRENT this stamp +// compares with (updateAvailable). Full DB round-trip (Controller->Repository->Neo4j). + +/** Write manual stamp to Project node (old version simulation). */ + async function stampVersion(v: number): Promise { + await neo4j.run( + `MATCH (p:Project {id:$id}) SET p.codegenVersion = toInteger($v)`, + { id: projectId, v }, + ); + } + + it("summary.version carries current Constructor version", async () => { + await seedGraph(); + const res = await request(app.getHttpServer()) + .post(`${base}/projects/${projectId}/codegen`).send({ target: "nestjs" }).expect(200); + expect(typeof res.body.data.summary.version).toBe("number"); + expect(res.body.data.summary.version).toBeGreaterThanOrEqual(1); + }); + + it("never generated -> status generated null + updateAvailable false", async () => { + const res = await request(app.getHttpServer()) + .get(`${base}/projects/${projectId}/codegen/status`).expect(200); + expect(res.body.success).toBe(true); + expect(res.body.data.generated).toBeNull(); + expect(res.body.data.updateAvailable).toBe(false); + expect(typeof res.body.data.current).toBe("number"); + }); + + it("after generate -> status generated = current + updateAvailable false (stamp persisted)", async () => { + await seedGraph(); + const gen = await request(app.getHttpServer()) + .post(`${base}/projects/${projectId}/codegen`).send({ target: "nestjs" }).expect(200); + const current = gen.body.data.summary.version; + + const res = await request(app.getHttpServer()) + .get(`${base}/projects/${projectId}/codegen/status`).expect(200); + expect(res.body.data.current).toBe(current); + expect(res.body.data.generated).toBe(current); + expect(res.body.data.updateAvailable).toBe(false); + }); + + it("stale stamp (current-1) -> updateAvailable true", async () => { + const cur = await request(app.getHttpServer()) + .get(`${base}/projects/${projectId}/codegen/status`).expect(200); + await stampVersion(cur.body.data.current - 1); + + const res = await request(app.getHttpServer()) + .get(`${base}/projects/${projectId}/codegen/status`).expect(200); + expect(res.body.data.generated).toBe(cur.body.data.current - 1); + expect(res.body.data.updateAvailable).toBe(true); + }); + + it("status: no project -> 404 ERR_PROJECT_NOT_FOUND", async () => { + const res = await request(app.getHttpServer()) + .get(`${base}/projects/11111111-1111-4111-8111-111111111111/codegen/status`) + .expect(404); + expect(res.body.error.code).toBe("ERR_PROJECT_NOT_FOUND"); + }); +}); diff --git a/apps/server/test/edges.e2e-spec.ts b/apps/server/test/edges.e2e-spec.ts new file mode 100644 index 0000000..ea6427b --- /dev/null +++ b/apps/server/test/edges.e2e-spec.ts @@ -0,0 +1,125 @@ +import "reflect-metadata"; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { Test } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import { ZodValidationPipe as ZodPipe } from "nestjs-zod"; +import express from "express"; +import request from "supertest"; +import { Neo4jContainer, StartedNeo4jContainer } from "@testcontainers/neo4j"; +import { AppModule } from "../src/app.module"; +import { Neo4jService } from "../src/neo4j/neo4j.service"; +import { SchemaErrorFilter } from "../src/common/filters/schema-error.filter"; +import { NotFoundFilter } from "../src/common/filters/not-found.filter"; +import { ConflictFilter } from "../src/common/filters/conflict.filter"; +import { InternalFilter } from "../src/common/filters/internal.filter"; +import { bypassAuth } from "./test-auth"; + +const projectId = "550e8400-e29b-41d4-a716-4466554400ed"; + +describe("Edges E2E (apoc.merge dedup)", () => { + let container: StartedNeo4jContainer; + let app: INestApplication; + let neo4j: Neo4jService; + + beforeAll(async () => { + container = await new Neo4jContainer("neo4j:5-community").withApoc().start(); + neo4j = new Neo4jService({ + uri: container.getBoltUri(), + user: container.getUsername(), + password: container.getPassword(), + }); + await neo4j.onModuleInit(); + await neo4j.run("CREATE CONSTRAINT node_id_unique IF NOT EXISTS FOR (n:Node) REQUIRE n.id IS UNIQUE"); + await neo4j.run("CREATE INDEX node_project_idx IF NOT EXISTS FOR (n:Node) ON (n.projectId)"); + await neo4j.run( + `CREATE (p:Project {id: $id, name: 'E2E Edges', description: 'test', status: 'draft', createdAt: datetime(), updatedAt: datetime()})`, + { id: projectId }, + ); + + const moduleRef = await bypassAuth( + Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(Neo4jService) + .useValue(neo4j), + ).compile(); + + app = moduleRef.createNestApplication(); + app.use(express.json()); + app.useGlobalPipes(new ZodPipe()); + app.setGlobalPrefix("api/v1"); + app.useGlobalFilters(new InternalFilter(), new ConflictFilter(), new NotFoundFilter(), new SchemaErrorFilter()); + await app.init(); + }, 180_000); + + afterAll(async () => { + await app.close(); + await neo4j.onModuleDestroy(); + await container.stop(); + }); + + beforeEach(async () => { + await neo4j.run("MATCH (n:Node) DETACH DELETE n"); + }); + + async function createNode(payload: object): Promise { + const res = await request(app.getHttpServer()) + .post(`/api/v1/projects/${projectId}/nodes`) + .send(payload) + .expect(201); + return res.body.data.id; + } + + const modelPayload = { + type: "Model", projectId, position: { x: 0, y: 0 }, + properties: { ClassName: "User", Description: "m", Properties: [{ Name: "status", Type: "OrderStatus" }], Methods: [] }, + }; + const enumPayload = { + type: "Enum", projectId, position: { x: 0, y: 0 }, + properties: { Name: "OrderStatus", Description: "e", BackingType: "string", Values: [{ Key: "PENDING" }] }, + }; + + it("Model -USES-> Enum: create (apoc.merge) + round-trip", async () => { + const src = await createNode(modelPayload); + const tgt = await createNode(enumPayload); + + const created = await request(app.getHttpServer()) + .post(`/api/v1/projects/${projectId}/edges`) + .send({ projectId, sourceNodeId: src, targetNodeId: tgt, kind: "USES", properties: { IsAsync: false } }) + .expect(201); + expect(created.body.success).toBe(true); + const edgeId = created.body.data.id; + expect(edgeId).toMatch(/^[0-9a-f-]{36}$/); + + const got = await request(app.getHttpServer()) + .get(`/api/v1/projects/${projectId}/edges/${edgeId}`) + .expect(200); + expect(got.body.data.kind).toBe("USES"); + expect(got.body.data.sourceNodeId).toBe(src); + expect(got.body.data.targetNodeId).toBe(tgt); + }); + +it("same (source,target,kind) second time → 409, double edge does not occur", async () => { + const src = await createNode(modelPayload); + const tgt = await createNode(enumPayload); + const body = { projectId, sourceNodeId: src, targetNodeId: tgt, kind: "USES", properties: { IsAsync: false } }; + + await request(app.getHttpServer()).post(`/api/v1/projects/${projectId}/edges`).send(body).expect(201); + const dup = await request(app.getHttpServer()).post(`/api/v1/projects/${projectId}/edges`).send(body).expect(409); + expect(dup.body.error.code).toBe("ERR_EDGE_DUPLICATE"); + + const list = await request(app.getHttpServer()).get(`/api/v1/projects/${projectId}/edges`).expect(200); + expect(list.body.data.total).toBe(1); // MERGE → tek edge + +// Verify that there is only one relationship at the DB level + const rels = await neo4j.run("MATCH ()-[r:USES]->() RETURN count(r) AS c"); + expect(Number(rels.records[0].get("c"))).toBe(1); + }); + + it("self-loop → 400 ERR_EDGE_SELF_LOOP", async () => { + const src = await createNode(modelPayload); + const res = await request(app.getHttpServer()) + .post(`/api/v1/projects/${projectId}/edges`) + .send({ projectId, sourceNodeId: src, targetNodeId: src, kind: "USES", properties: { IsAsync: false } }) + .expect(400); + expect(res.body.error.code).toBe("ERR_EDGE_SELF_LOOP"); + }); +}); diff --git a/apps/server/test/health.e2e-spec.ts b/apps/server/test/health.e2e-spec.ts new file mode 100644 index 0000000..5823bf6 --- /dev/null +++ b/apps/server/test/health.e2e-spec.ts @@ -0,0 +1,86 @@ +import "reflect-metadata"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Test } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import { ZodValidationPipe as ZodPipe } from "nestjs-zod"; +import express from "express"; +import request from "supertest"; +import { Neo4jContainer, StartedNeo4jContainer } from "@testcontainers/neo4j"; +import { AppModule } from "../src/app.module"; +import { Neo4jService } from "../src/neo4j/neo4j.service"; +import { InternalFilter } from "../src/common/filters/internal.filter"; +import { bypassAuth } from "./test-auth"; + +describe("Health E2E (liveness + readiness)", () => { + let container: StartedNeo4jContainer; + let app: INestApplication; + let neo4j: Neo4jService; + + beforeAll(async () => { + container = await new Neo4jContainer("neo4j:5-community").withApoc().start(); + neo4j = new Neo4jService({ + uri: container.getBoltUri(), + user: container.getUsername(), + password: container.getPassword(), + }); + await neo4j.onModuleInit(); + + const moduleRef = await bypassAuth( + Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(Neo4jService) + .useValue(neo4j), + ).compile(); + + app = moduleRef.createNestApplication(); + app.use(express.json()); + app.useGlobalPipes(new ZodPipe()); + app.setGlobalPrefix("api/v1"); + app.useGlobalFilters(new InternalFilter()); + await app.init(); + }, 180_000); + + afterAll(async () => { + await app.close(); + await neo4j.onModuleDestroy(); + await container.stop(); + }); + + it("liveness: GET /health → 200 status ok", async () => { + const res = await request(app.getHttpServer()).get("/api/v1/health").expect(200); + expect(res.body.data.status).toBe("ok"); + }); + + it("readiness: GET /health/ready → 200 status ready (Neo4j up)", async () => { + const res = await request(app.getHttpServer()).get("/api/v1/health/ready").expect(200); + expect(res.body.data.status).toBe("ready"); + }); + + it("readiness: Neo4j down → 503 ERR_NOT_READY", async () => { + // ping override-throw → readiness 503 (process/liveness etkilenmez). + const spy = neo4j.ping; + (neo4j as { ping: () => Promise }).ping = async () => { throw new Error("down"); }; + try { + const res = await request(app.getHttpServer()).get("/api/v1/health/ready").expect(503); + expect(res.body.error.code).toBe("ERR_NOT_READY"); +// liveness is still 200 (process should not be killed in DB down) + await request(app.getHttpServer()).get("/api/v1/health").expect(200); + } finally { + (neo4j as { ping: typeof spy }).ping = spy; + } + }); + +it("readiness: fast 503 (network partition) with timeout if ping hangs", async () => { +// ping should not resolve at all (partition dropping packet) → controller pingWithTimeout(2s) +// must compete with; The probe should return 503 in ~2s, it should not wait for the 30-60s driver timeout. + const spy = neo4j.ping; + (neo4j as { ping: () => Promise }).ping = () => new Promise(() => {}); + const start = Date.now(); + try { + const res = await request(app.getHttpServer()).get("/api/v1/health/ready").expect(503); + expect(res.body.error.code).toBe("ERR_NOT_READY"); + expect(Date.now() - start).toBeLessThan(5_000); // 2s timeout + tampon + } finally { + (neo4j as { ping: typeof spy }).ping = spy; + } + }, 10_000); +}); diff --git a/apps/server/test/nodes.e2e-spec.ts b/apps/server/test/nodes.e2e-spec.ts new file mode 100644 index 0000000..3eeeb14 --- /dev/null +++ b/apps/server/test/nodes.e2e-spec.ts @@ -0,0 +1,203 @@ +import "reflect-metadata"; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { Test } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import { ZodValidationPipe as ZodPipe } from "nestjs-zod"; +import express from "express"; +import request from "supertest"; +import { Neo4jContainer, StartedNeo4jContainer } from "@testcontainers/neo4j"; +import { AppModule } from "../src/app.module"; +import { Neo4jService } from "../src/neo4j/neo4j.service"; +import { SchemaErrorFilter } from "../src/common/filters/schema-error.filter"; +import { NotFoundFilter } from "../src/common/filters/not-found.filter"; +import { ConflictFilter } from "../src/common/filters/conflict.filter"; +import { InternalFilter } from "../src/common/filters/internal.filter"; +import { bypassAuth } from "./test-auth"; + +const projectId = "550e8400-e29b-41d4-a716-446655440001"; + +describe("Nodes E2E", () => { + let container: StartedNeo4jContainer; + let app: INestApplication; + let neo4j: Neo4jService; + + beforeAll(async () => { + container = await new Neo4jContainer("neo4j:5-community").withApoc().start(); + + neo4j = new Neo4jService({ + uri: container.getBoltUri(), + user: container.getUsername(), + password: container.getPassword(), + }); + await neo4j.onModuleInit(); + await neo4j.run("CREATE CONSTRAINT node_id_unique IF NOT EXISTS FOR (n:Node) REQUIRE n.id IS UNIQUE"); + await neo4j.run("CREATE INDEX node_project_idx IF NOT EXISTS FOR (n:Node) ON (n.projectId)"); +// Strict referential integrity — project must exist to create node + await neo4j.run( + `CREATE (p:Project {id: $id, name: 'E2E Test', description: 'test', status: 'draft', createdAt: datetime(), updatedAt: datetime()})`, + { id: projectId }, + ); + + const moduleRef = await bypassAuth( + Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(Neo4jService) + .useValue(neo4j), + ).compile(); + + app = moduleRef.createNestApplication(); + app.use(express.json()); + app.useGlobalPipes(new ZodPipe()); + app.setGlobalPrefix("api/v1"); + app.useGlobalFilters( + new InternalFilter(), + new ConflictFilter(), + new NotFoundFilter(), + new SchemaErrorFilter(), + ); + await app.init(); + }, 180_000); + + afterAll(async () => { + await app.close(); + await neo4j.onModuleDestroy(); + await container.stop(); + }); + + beforeEach(async () => { + await neo4j.run("MATCH (n:Node) DETACH DELETE n"); + }); + + const fixtures = { + Table: { + type: "Table" as const, + projectId, + position: { x: 0, y: 0 }, + properties: { + TableName: "users", + Description: "u", + Columns: [ + { Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }, + { Name: "org_id", DataType: "UUID", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + { Name: "balance", DataType: "DECIMAL", Precision: 12, Scale: 2, IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, + ], + ForeignKeys: [{ Columns: ["org_id"], ReferencesTable: "orgs", ReferencesColumns: ["id"], OnDelete: "CASCADE" }], + CheckConstraints: [{ Name: "balance_nonneg", Expression: "balance >= 0" }], + Indexes: [{ IndexName: "idx_org", Columns: ["org_id"], Type: "BTree", IsUnique: false }], + }, + }, + DTO: { + type: "DTO" as const, + projectId, + position: { x: 0, y: 0 }, + properties: { + Name: "CreateUserDTO", + Description: "d", + Fields: [{ Name: "email", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [{ Rule: "Email" }, { Rule: "MaxLength", Value: "255" }] }], + }, + }, + Model: { + type: "Model" as const, + projectId, + position: { x: 0, y: 0 }, + properties: { + ClassName: "User", + Description: "m", + Properties: [{ Name: "id", Type: "UUID" }], + Methods: [], + }, + }, + Enum: { + type: "Enum" as const, + projectId, + position: { x: 0, y: 0 }, + properties: { Name: "OrderStatus", Description: "e", BackingType: "string", Values: [{ Key: "PENDING" }, { Key: "SHIPPED", Value: "shipped" }] }, + }, + View: { + type: "View" as const, + projectId, + position: { x: 0, y: 0 }, + properties: { ViewName: "active_users", Description: "v", Definition: "SELECT 1", SourceTables: ["users"], Materialized: false }, + }, + }; + + for (const [kind, payload] of Object.entries(fixtures)) { + it(`${kind}: full CRUD round-trip`, async () => { + const created = await request(app.getHttpServer()) + .post(`/api/v1/projects/${projectId}/nodes`) + .send(payload) + .expect(201); + expect(created.body.success).toBe(true); + const id = created.body.data.id; + expect(id).toMatch(/^[0-9a-f-]{36}$/); + + const got = await request(app.getHttpServer()) + .get(`/api/v1/projects/${projectId}/nodes/${id}`) + .expect(200); + expect(got.body.data.type).toBe(kind); + + const listed = await request(app.getHttpServer()) + .get(`/api/v1/projects/${projectId}/nodes?type=${kind}`) + .expect(200); + expect(listed.body.data.total).toBe(1); + + const patched = await request(app.getHttpServer()) + .patch(`/api/v1/projects/${projectId}/nodes/${id}`) + .send({ position: { x: 999, y: 888 } }) + .expect(200); + expect(patched.body.data.position).toEqual({ x: 999, y: 888 }); + + await request(app.getHttpServer()) + .delete(`/api/v1/projects/${projectId}/nodes/${id}`) + .expect(204); + + const notFound = await request(app.getHttpServer()) + .get(`/api/v1/projects/${projectId}/nodes/${id}`) + .expect(404); + expect(notFound.body.error.code).toBe("ERR_NODE_NOT_FOUND"); + }); + } + + it("ERR_SCHEMA_INVALID — Description eksik", async () => { + const payload = JSON.parse(JSON.stringify(fixtures.Table)); + delete payload.properties.Description; + const res = await request(app.getHttpServer()) + .post(`/api/v1/projects/${projectId}/nodes`) + .send(payload) + .expect(400); + expect(res.body.error.code).toBe("ERR_SCHEMA_INVALID"); + expect(res.body.error.details).toBeDefined(); + }); + +it("ERR_PROJECT_MISMATCH — URL and body do not match", async () => { + const res = await request(app.getHttpServer()) + .post(`/api/v1/projects/${"550e8400-e29b-41d4-a716-446655449999"}/nodes`) + .send(fixtures.Table) + .expect(400); + expect(res.body.error.code).toBe("ERR_PROJECT_MISMATCH"); + }); + +it("ERR_NAME_DUPLICATE — same TableName second time", async () => { + await request(app.getHttpServer()) + .post(`/api/v1/projects/${projectId}/nodes`) + .send(fixtures.Table) + .expect(201); + const res = await request(app.getHttpServer()) + .post(`/api/v1/projects/${projectId}/nodes`) + .send(fixtures.Table) + .expect(409); + expect(res.body.error.code).toBe("ERR_NAME_DUPLICATE"); + }); + +it("ERR_KIND_IMMUTABLE — If PATCH tries to change type", async () => { + const created = await request(app.getHttpServer()) + .post(`/api/v1/projects/${projectId}/nodes`) + .send(fixtures.Table); + const res = await request(app.getHttpServer()) + .patch(`/api/v1/projects/${projectId}/nodes/${created.body.data.id}`) + .send({ type: "DTO" }) + .expect(400); + expect(["ERR_KIND_IMMUTABLE", "ERR_SCHEMA_INVALID"]).toContain(res.body.error.code); + }); +}); diff --git a/apps/server/test/patterns.e2e-spec.ts b/apps/server/test/patterns.e2e-spec.ts new file mode 100644 index 0000000..cd03da0 --- /dev/null +++ b/apps/server/test/patterns.e2e-spec.ts @@ -0,0 +1,122 @@ +import "reflect-metadata"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Test } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import { ZodValidationPipe as ZodPipe } from "nestjs-zod"; +import express from "express"; +import request from "supertest"; +import { Neo4jContainer, StartedNeo4jContainer } from "@testcontainers/neo4j"; +import { AppModule } from "../src/app.module"; +import { Neo4jService } from "../src/neo4j/neo4j.service"; +import { EMBEDDINGS } from "../src/embeddings/embeddings.types"; +import { PatternsService } from "../src/patterns/patterns.service"; +import { env } from "../src/config/env"; +import { SchemaErrorFilter } from "../src/common/filters/schema-error.filter"; +import { NotFoundFilter } from "../src/common/filters/not-found.filter"; +import { ConflictFilter } from "../src/common/filters/conflict.filter"; +import { InternalFilter } from "../src/common/filters/internal.filter"; +import { bypassAuth } from "./test-auth"; + +// Deterministic fake embedder — tests the vector index without downloading the real model. +function fakeVec(text: string): number[] { + const dim = env.EMBED_DIM; + const v = new Array(dim).fill(0); + for (let i = 0; i < text.length; i++) v[i % dim] += text.charCodeAt(i); + const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1; + return v.map((x) => x / norm); +} +const fakeEmbeddings = { + isConfigured: () => true, + embed: async (t: string) => fakeVec(t), + embedBatch: async (ts: string[]) => ts.map(fakeVec), +}; + +/* Patterns library READ-ALONE + seed-scoped (BOLA fix). writing nibs +* (create/delete/promote) removed; Seeding is done with the service. This is e2e: +* (1) seed pattern reading round-trip, (2) SECURITY: 'promoted' source pattern +* does not return from any reading path (list/search). */ +describe("Patterns E2E (read-only, seed-scoped)", () => { + let container: StartedNeo4jContainer; + let app: INestApplication; + let neo4j: Neo4jService; + let service: PatternsService; + const base = "/api/v1"; + + const graph = { + nodes: [{ tempId: "t_ctrl", type: "Controller", properties: { ControllerName: "X", Description: "d", BaseRoute: "/x", Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: false }] } }], + edges: [], + }; + + beforeAll(async () => { + container = await new Neo4jContainer("neo4j:5-community").withApoc().start(); + neo4j = new Neo4jService({ uri: container.getBoltUri(), user: container.getUsername(), password: container.getPassword() }); + await neo4j.onModuleInit(); + await neo4j.run( + `CREATE VECTOR INDEX pattern_embedding IF NOT EXISTS + FOR (p:Pattern) ON (p.embedding) + OPTIONS { indexConfig: { \`vector.dimensions\`: ${env.EMBED_DIM}, \`vector.similarity_function\`: 'cosine' } }`, + ); + + const moduleRef = await bypassAuth( + Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(Neo4jService).useValue(neo4j) + .overrideProvider(EMBEDDINGS).useValue(fakeEmbeddings), + ).compile(); + + service = moduleRef.get(PatternsService); + app = moduleRef.createNestApplication(); + app.use(express.json()); + app.useGlobalPipes(new ZodPipe()); + app.setGlobalPrefix("api/v1"); + app.useGlobalFilters(new InternalFilter(), new ConflictFilter(), new NotFoundFilter(), new SchemaErrorFilter()); + await app.init(); + await neo4j.run(`MATCH (p:Pattern) DELETE p`); + }, 180_000); + + afterAll(async () => { + await neo4j.run(`MATCH (p:Pattern) DELETE p`); + await app.close(); + await neo4j.onModuleDestroy(); + await container.stop(); + }); + + it("seed pattern → list + getById + search round-trip", async () => { +const seeded = await service.create({ name: "Auth flow", description: "JWT login authentication", tags: ["auth"], graph } as any, "seed"); +expect(seeded.name).toBe("Auth flow"); + + const list = await request(app.getHttpServer()).get(`${base}/patterns`).expect(200); +expect(list.body.data.some((p: { name: string }) => p.name === "Auth flow")).toBe(true); + + const one = await request(app.getHttpServer()).get(`${base}/patterns/${seeded.id}`).expect(200); +expect(one.body.data.name).toBe("Auth flow"); + +await new Promise((r) => setTimeout(r, 1500)); // vector index eventual + const search = await request(app.getHttpServer()) + .post(`${base}/patterns/search`).send({ query: "JWT login authentication", k: 5, minScore: 0 }).expect(200); + expect(search.body.data.length).toBeGreaterThanOrEqual(1); +expect(search.body.data[0].pattern.name).toBe("Auth flow"); + }); + + it("getById olmayan → 404", async () => { + await request(app.getHttpServer()).get(`${base}/patterns/00000000-0000-0000-0000-000000000000`).expect(404); + }); + +it("SECURITY: 'promoted' pattern does NOT return from read paths (list/getById/search)", async () => { +const desc = "CONFIDENTIAL promoted tenant architecture sensitive"; + const id = "11111111-2222-4333-8444-555555555555"; + await neo4j.run( + `CREATE (p:Pattern { id:$id, name:'Gizli Promoted', description:$desc, tags:[], graphJson:'{"nodes":[],"edges":[]}', source:'promoted', createdAt: datetime(), embedding:$emb })`, + { id, desc, emb: fakeVec(desc) }, + ); + await new Promise((r) => setTimeout(r, 1500)); + + const list = await request(app.getHttpServer()).get(`${base}/patterns`).expect(200); + expect(list.body.data.some((p: { name: string }) => p.name === "Gizli Promoted")).toBe(false); + + await request(app.getHttpServer()).get(`${base}/patterns/${id}`).expect(404); + + const search = await request(app.getHttpServer()) + .post(`${base}/patterns/search`).send({ query: desc, k: 10, minScore: 0 }).expect(200); + expect(search.body.data.some((h: { pattern: { name: string } }) => h.pattern.name === "Gizli Promoted")).toBe(false); + }); +}); diff --git a/apps/server/test/tabs.e2e-spec.ts b/apps/server/test/tabs.e2e-spec.ts new file mode 100644 index 0000000..796698a --- /dev/null +++ b/apps/server/test/tabs.e2e-spec.ts @@ -0,0 +1,113 @@ +import "reflect-metadata"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Test } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import { ZodValidationPipe as ZodPipe } from "nestjs-zod"; +import express from "express"; +import request from "supertest"; +import { Neo4jContainer, StartedNeo4jContainer } from "@testcontainers/neo4j"; +import { AppModule } from "../src/app.module"; +import { Neo4jService } from "../src/neo4j/neo4j.service"; +import { SchemaErrorFilter } from "../src/common/filters/schema-error.filter"; +import { NotFoundFilter } from "../src/common/filters/not-found.filter"; +import { ConflictFilter } from "../src/common/filters/conflict.filter"; +import { InternalFilter } from "../src/common/filters/internal.filter"; +import { bypassAuth } from "./test-auth"; + +describe("Tabs E2E", () => { + let container: StartedNeo4jContainer; + let app: INestApplication; + let neo4j: Neo4jService; + const base = "/api/v1"; + let projectId: string; + let nodeId: string; + + beforeAll(async () => { + container = await new Neo4jContainer("neo4j:5-community").withApoc().start(); + neo4j = new Neo4jService({ + uri: container.getBoltUri(), + user: container.getUsername(), + password: container.getPassword(), + }); + await neo4j.onModuleInit(); + await neo4j.run("CREATE CONSTRAINT node_id_unique IF NOT EXISTS FOR (n:Node) REQUIRE n.id IS UNIQUE"); + await neo4j.run("CREATE CONSTRAINT tab_id_unique IF NOT EXISTS FOR (t:Tab) REQUIRE t.id IS UNIQUE"); + + const moduleRef = await bypassAuth( + Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(Neo4jService).useValue(neo4j), + ).compile(); + + app = moduleRef.createNestApplication(); + app.use(express.json()); + app.useGlobalPipes(new ZodPipe()); + app.setGlobalPrefix("api/v1"); + app.useGlobalFilters(new InternalFilter(), new ConflictFilter(), new NotFoundFilter(), new SchemaErrorFilter()); + await app.init(); + }, 180_000); + + afterAll(async () => { + await app.close(); + await neo4j.onModuleDestroy(); + await container.stop(); + }); + +it("When the project is opened, the Main Architecture tab appears", async () => { + const p = await request(app.getHttpServer()).post(`${base}/projects`).send({ name: "Tab E2E" }).expect(201); + projectId = p.body.data.id; + const tabs = await request(app.getHttpServer()).get(`${base}/projects/${projectId}/tabs`).expect(200); + expect(tabs.body.data).toHaveLength(1); + expect(tabs.body.data[0].isDefault).toBe(true); + expect(tabs.body.data[0].name).toBe("Main Architecture"); + }); + +it("node hosts the tab by default, the tab appears owned in the graph", async () => { + const n = await request(app.getHttpServer()).post(`${base}/projects/${projectId}/nodes`).send({ + projectId, position: { x: 10, y: 20 }, type: "Service", + properties: { ServiceName: "OrderSvc", Description: "d", IsTransactionScoped: false, Methods: [{ MethodName: "x", ReturnType: "void" }] }, + }).expect(201); + nodeId = n.body.data.id; + const tabs = await request(app.getHttpServer()).get(`${base}/projects/${projectId}/tabs`).expect(200); + const defId = tabs.body.data[0].id; + const g = await request(app.getHttpServer()).get(`${base}/projects/${projectId}/tabs/${defId}/graph`).expect(200); + expect(g.body.data.nodes).toHaveLength(1); + expect(g.body.data.nodes[0].isReference).toBe(false); + expect(g.body.data.nodes[0].position).toEqual({ x: 10, y: 20 }); + }); + + it("yeni sekme + node import (referans) round-trip", async () => { +const t = await request(app.getHttpServer()).post(`${base}/projects/${projectId}/tabs`).send({ name: "Order" }).expect(201); + const tabId = t.body.data.id; + await request(app.getHttpServer()).put(`${base}/projects/${projectId}/tabs/${tabId}/references/${nodeId}`).send({ x: 99, y: 88 }).expect(200); + const g = await request(app.getHttpServer()).get(`${base}/projects/${projectId}/tabs/${tabId}/graph`).expect(200); + expect(g.body.data.nodes).toHaveLength(1); + expect(g.body.data.nodes[0].isReference).toBe(true); + expect(g.body.data.nodes[0].position).toEqual({ x: 99, y: 88 }); + await request(app.getHttpServer()).delete(`${base}/projects/${projectId}/tabs/${tabId}/references/${nodeId}`).expect(204); + const g2 = await request(app.getHttpServer()).get(`${base}/projects/${projectId}/tabs/${tabId}/graph`).expect(200); + expect(g2.body.data.nodes).toHaveLength(0); + }); + + it("node kendi ev sekmesine referans edilemez (400)", async () => { + const tabs = await request(app.getHttpServer()).get(`${base}/projects/${projectId}/tabs`).expect(200); + const defId = tabs.body.data.find((t: any) => t.isDefault).id; + await request(app.getHttpServer()).put(`${base}/projects/${projectId}/tabs/${defId}/references/${nodeId}`).send({ x: 1, y: 1 }).expect(400); + }); + + it("default sekme silinemez (400)", async () => { + const tabs = await request(app.getHttpServer()).get(`${base}/projects/${projectId}/tabs`).expect(200); + const defId = tabs.body.data.find((t: any) => t.isDefault).id; + await request(app.getHttpServer()).delete(`${base}/projects/${projectId}/tabs/${defId}`).expect(400); + }); + +it("when the tab is deleted, the owned node is moved to the Main Architecture, the node is not lost", async () => { +const t = await request(app.getHttpServer()).post(`${base}/projects/${projectId}/tabs`).send({ name: "Temporary" }).expect(201); + const tabId = t.body.data.id; + const n = await request(app.getHttpServer()).post(`${base}/projects/${projectId}/nodes`).send({ + projectId, position: { x: 1, y: 1 }, homeTabId: tabId, type: "Cache", + properties: { CacheName: "C", Description: "d", KeyPattern: "k", TTL_Seconds: 60, Engine: "Redis" }, + }).expect(201); + await request(app.getHttpServer()).delete(`${base}/projects/${projectId}/tabs/${tabId}`).expect(204); + await request(app.getHttpServer()).get(`${base}/projects/${projectId}/nodes/${n.body.data.id}`).expect(200); + }); +}); diff --git a/apps/server/test/test-auth.ts b/apps/server/test/test-auth.ts new file mode 100644 index 0000000..8765824 --- /dev/null +++ b/apps/server/test/test-auth.ts @@ -0,0 +1,39 @@ +import type { TestingModuleBuilder } from "@nestjs/testing"; +import { UnauthorizedException } from "@nestjs/common"; +import { LocalAuthGuard } from "../src/auth/local-auth.guard"; +import { ProjectAccessGuard } from "../src/auth/project-access.guard"; + +export const TEST_AUTH = { userId: "user_test", orgId: null as string | null, orgRole: null as string | null }; + +/** For existing e2e: bypass authentication with a fixed user and +* bypass project access guard (these tests do not test tenancy). */ +export function bypassAuth(builder: TestingModuleBuilder): TestingModuleBuilder { + return builder +// LocalAuthGuard bound to APP_GUARD with useExisting → overrideProvider replaces global guard. + .overrideProvider(LocalAuthGuard) + .useValue({ + canActivate: (ctx: { switchToHttp: () => { getRequest: () => { auth?: unknown } } }) => { + ctx.switchToHttp().getRequest().auth = TEST_AUTH; + return true; + }, + }) + .overrideGuard(ProjectAccessGuard) + .useValue({ canActivate: () => true }); +} + +/** Header-driven identity stub for auth.e2e: from x-test-user / x-test-org headers +* generates req.auth; If there is no header, 401. ProjectAccessGuard remains TRUE (BOLA test). */ +export function headerAuthGuardValue() { + return { + canActivate: (ctx: { switchToHttp: () => { getRequest: () => Record } }) => { + const req = ctx.switchToHttp().getRequest(); + const userId = req.headers["x-test-user"] as string | undefined; + if (!userId) { +throw new UnauthorizedException({ code: "ERR_UNAUTHORIZED", message: "Authentication required." }); + } + const orgId = (req.headers["x-test-org"] as string | undefined) ?? null; + req.auth = { userId, orgId, orgRole: null }; + return true; + }, + }; +} diff --git a/apps/server/tsconfig.build.json b/apps/server/tsconfig.build.json new file mode 100644 index 0000000..cc01185 --- /dev/null +++ b/apps/server/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*.spec.ts"] +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..3e8ac12 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts new file mode 100644 index 0000000..4f0d109 --- /dev/null +++ b/apps/server/vitest.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + test: { + include: ["src/**/*.spec.ts"], + environment: "node", + globals: false, + testTimeout: 10000, + // env.ts runs top-level `parseEnv(process.env)`; unit tests have no real + // .env file. Provide minimal defaults so module import does not crash (e2e script + // already does the same via npm env vars). parseEnv tests pass explicit object + // so they are unaffected by this. + env: { + NEO4J_URI: "bolt://localhost:7687", + NEO4J_USER: "neo4j", + NEO4J_PASSWORD: "test", + LLM_GENERATION_PROVIDER: "openai", + LLM_CHAT_PROVIDER: "openai", + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/apps/server/vitest.e2e.config.ts b/apps/server/vitest.e2e.config.ts new file mode 100644 index 0000000..9ae377d --- /dev/null +++ b/apps/server/vitest.e2e.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "vitest/config"; +import swc from "unplugin-swc"; +import path from "node:path"; + +export default defineConfig({ + plugins: [ +//emitDecoratorMetadata required for NestJS DI to work — esbuild this + // desteklemiyor, SWC transformer ile decorator metadata korunur. + swc.vite({ + module: { type: "es6" }, + jsc: { + target: "es2022", + parser: { syntax: "typescript", decorators: true }, + transform: { decoratorMetadata: true, legacyDecorator: true }, + }, + }), + ], + test: { + include: ["test/**/*.e2e-spec.ts"], + environment: "node", + globals: false, + testTimeout: 180_000, + hookTimeout: 180_000, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/apps/server/vitest.gate.config.ts b/apps/server/vitest.gate.config.ts new file mode 100644 index 0000000..82251a8 --- /dev/null +++ b/apps/server/vitest.gate.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +/* Whole-project tsc gate (codegen-tsc.gate.test.ts) — SEPARATE config. +* Slow + requires node_modules; By default it does not explore `*.spec.ts`. +* Run on CI with `pnpm test:codegen-gate`. */ +export default defineConfig({ + test: { + include: ["src/**/*.gate.test.ts"], + environment: "node", + globals: false, + testTimeout: 600_000, + hookTimeout: 600_000, +//minimal defaults to prevent env.ts top-level parseEnv from exploding during import +// (same as default geart.config). + env: { + NEO4J_URI: "bolt://localhost:7687", + NEO4J_USER: "neo4j", + NEO4J_PASSWORD: "test", + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..5db8512 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,2 @@ +# Empty = same-origin (/api proxied by Caddy in Docker). +VITE_API_URL= diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..d4994e7 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,37 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# local env (may contain secrets) +.env + +# design-sync (claude.ai/design) — staged scripts, build output, machine state +.ds-sync/ +ds-bundle/ +.design-sync/.cache/ +.design-sync/learnings/ +.design-sync/node_modules + +# local shell tool (not part of the project) +ble.sh/ diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..3e7678a --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 +# Solarch web (Vite SPA) — built, then served by Caddy which reverse-proxies /api to the server. + +FROM node:22-slim AS build +RUN corepack enable && corepack prepare pnpm@10.0.0 --activate +WORKDIR /app +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml turbo.json tsconfig.base.json ./ +COPY apps/web/package.json apps/web/package.json +COPY apps/server/package.json apps/server/package.json +RUN pnpm install --frozen-lockfile --filter @solarch/web... +COPY apps/web apps/web +ARG VITE_API_URL="" +ENV VITE_API_URL=$VITE_API_URL +RUN pnpm --filter @solarch/web build + +FROM caddy:2-alpine +COPY deploy/Caddyfile.base /etc/caddy/Caddyfile.base +COPY deploy/Caddyfile.auth.snippet /etc/caddy/Caddyfile.auth.snippet +COPY deploy/web-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +COPY --from=build /app/apps/web/dist /srv +EXPOSE 3000 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..536fda9 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,43 @@ +# Solarch Web (OSS) + +Architecture builder UI for the self-hosted Solarch monorepo. Custom Canvas 2D engine, rule-driven interactions, and a natural-language AI OmniBar — no login screen. + +## Stack + +- **Vite + React 19 + TypeScript** — fast HMR +- **Custom Canvas 2D** — under `src/canvas/` (dual-canvas, viewport culling) +- **openapi-fetch + openapi-typescript** — typed API client from backend OpenAPI +- **TanStack Query 5** — server state +- **Zustand** — view-local state (zoom, pan, selection, theme) +- **react-router-dom 7** — `/start`, `/projects/:id/:tabId` + +## Development + +```bash +pnpm install +pnpm dev:web # http://localhost:5173 — proxies /api → :4000 +pnpm build:web +``` + +Run the API from `../server` or `docker compose up` from the monorepo root. + +## Key folders + +``` +src/ +├── api/ # typed client + TanStack Query hooks +├── canvas/ # render engine (outside React) +├── components/ # TopBar, OmniBar, Inspector, … +├── features/ # canvas, codegen, settings, welcome +└── state/ # workspace + theme stores +``` + +## Documentation + +- [Root README](../../README.md) — product overview & quick start +- [Docs index](../../docs/README.md) — full guide list +- [Getting started](../../docs/getting-started.md) — four surfaces tour + +## License + +[PolyForm Noncommercial](../../LICENSE) diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..159c778 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..ba3c0ee --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,31 @@ + + + + + + + + + + + + + Solarch + + +
    + + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..d713d51 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,71 @@ +{ + "name": "@solarch/web", + "private": true, + "version": "0.1.0", + "type": "module", + "engines": { + "node": ">=20.19.0" + }, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@dagrejs/dagre": "^3.0.0", + "@excalidraw/mermaid-to-excalidraw": "^2.2.2", + "@fortawesome/free-solid-svg-icons": "^7.2.0", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.100.12", + "@zxcvbn-ts/core": "^3.0.4", + "@zxcvbn-ts/language-common": "^3.0.4", + "@zxcvbn-ts/language-en": "^3.0.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "elkjs": "^0.11.1", + "jszip": "^3.10.1", + "lucide-react": "^1.16.0", + "openapi-fetch": "^0.17.0", + "prism-react-renderer": "^2.4.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.15.1", + "remark-gfm": "^4.0.1", + "roughjs": "^4.6.6", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "vaul": "^1.1.2", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.5.0", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "openapi-typescript": "^7.13.0", + "postcss": "^8.5.15", + "tailwindcss": "^3.4.19", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..6c01309 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/public/fonts/Excalifont.woff2 b/apps/web/public/fonts/Excalifont.woff2 new file mode 100644 index 0000000..98b9616 Binary files /dev/null and b/apps/web/public/fonts/Excalifont.woff2 differ diff --git a/apps/web/public/icons.svg b/apps/web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/apps/web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg new file mode 100644 index 0000000..1f9a427 --- /dev/null +++ b/apps/web/public/logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/public/solarch.svg b/apps/web/public/solarch.svg new file mode 100644 index 0000000..9b6dfb5 --- /dev/null +++ b/apps/web/public/solarch.svg @@ -0,0 +1,278 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/api/ai.ts b/apps/web/src/api/ai.ts new file mode 100644 index 0000000..0ca3f8f --- /dev/null +++ b/apps/web/src/api/ai.ts @@ -0,0 +1,320 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api, unwrap } from "./client"; +import { API_URL } from "../lib/env"; +import type { TabGraphData, TabGraphEdge, TabGraphMember } from "./tabs"; + +export interface ChatResult { + reply: string; + applied?: { idMap: Record; nodeCount: number; edgeCount: number }; + attempts: number; +} + +/** Legacy one-shot — chat() endpoint (monolithic apply_architecture_graph). + * Prefer useAiChatStream for streaming. */ +export function useAiChat(projectId: string, tabId: string | null) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (message: string) => + unwrap( + await api.POST("/api/v1/projects/{projectId}/ai/chat", { + params: { path: { projectId } }, + body: { message, tabId: tabId ?? undefined } as never, + }), + ), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["tab-graph", projectId, tabId] }); + qc.invalidateQueries({ queryKey: ["tabs", projectId] }); + }, + }); +} + +// ──────────────────────────────────────────────────────────── +// Streaming agent — SSE + atomic create_node/create_edge tools +// ──────────────────────────────────────────────────────────── + +type StreamStatus = "idle" | "streaming" | "done" | "error" | "paused"; +export type AiMode = "agent" | "instruct"; + +interface BackendNode { + id: string; + type: string; + projectId: string; + position: { x: number; y: number }; + createdAt: string; + updatedAt: string; + version: number; + properties: Record; +} + +interface BackendEdge { + id: string; + projectId: string; + sourceNodeId: string; + targetNodeId: string; + kind: string; + createdAt: string; + updatedAt: string; + properties: Record; +} + +export interface AiStreamState { + status: StreamStatus; + mode: AiMode; + progress: { nodes: number; edges: number }; + /** instruct mode: live token-by-token accumulated text (typewriter). + * agent mode: unused (stays empty). */ + accumulatedText: string; + /** done event message (final for both modes). */ + message: string | null; + error: string | null; + /** Whether the error is retryable (provider/timeout/connection) → show "Try again". + * Plan/quota (402) is NOT retryable (upgrade required). */ + retryable: boolean; +} + +// ── Active stream counter ───────────────────────────────────────────── +// Canvas auto-triggers arrange on NEW edges arriving during AI generation; +// manually drawn edges don't trigger it. A module-level counter provides this +// distinction (independent of hook instances — OmniBar + InlineAiPrompt may run at once). +let activeStreams = 0; +let lastStreamEndAt = 0; +/** Whether AI generation is active — stream open OR just closed (post-stream + * invalidate/refetch edges also count as AI generation, trigger arrange). */ +export function isAiActive(graceMs = 4000): boolean { + return activeStreams > 0 || Date.now() - lastStreamEndAt < graceMs; +} + +/** Live-listen to generated element ids (the inline suggestion flow fills its + * pending set with these). Called AFTER the cache update. */ +export interface AiStreamCallbacks { + onNode?: (id: string) => void; + onEdge?: (id: string) => void; + /** Backend terminal rollback — element deleted from DB, must drop from the set too. */ + onRemoved?: (id: string, kind: "node" | "edge") => void; +} + +/** AI architect streaming — backend pushes SSE event after each create_node/create_edge + * tool execution. Hook incrementally updates React Query cache; canvas buildScene is + * diff-aware so new nodes appear with pop animation. + * + * start(message): open new stream (abort previous if any). + * abort(): close active stream. */ +export function useAiChatStream(projectId: string, tabId: string | null, callbacks?: AiStreamCallbacks) { + const qc = useQueryClient(); + const esRef = useRef(null); + // Callback identity can change on every render (inline object) — read via ref + // so that start() stays stable. + const cbRef = useRef(callbacks); + useEffect(() => { + cbRef.current = callbacks; + }, [callbacks]); + // Last request (message+mode) — "Continue" reopens the same request with continue=true. + const lastReqRef = useRef<{ message: string; mode: AiMode }>({ message: "", mode: "agent" }); + // React buffering: text-delta chunks land in useRef buffer; flushed to setState + // once per frame via rAF → 60fps maintained, no render spam. + const textBufferRef = useRef([]); + const flushScheduledRef = useRef(false); + + const [state, setState] = useState({ + status: "idle", + mode: "agent", + progress: { nodes: 0, edges: 0 }, + accumulatedText: "", + message: null, + error: null, + retryable: false, + }); + + const flushText = useCallback(() => { + flushScheduledRef.current = false; + const chunks = textBufferRef.current; + if (chunks.length === 0) return; + textBufferRef.current = []; + const concat = chunks.join(""); + setState((s) => ({ ...s, accumulatedText: s.accumulatedText + concat })); + }, []); + + const close = useCallback(() => { + if (esRef.current) { + esRef.current.close(); + esRef.current = null; + activeStreams = Math.max(0, activeStreams - 1); + if (activeStreams === 0) lastStreamEndAt = Date.now(); + } + }, []); + + const abort = useCallback(() => { + close(); + setState((s) => ({ ...s, status: s.status === "streaming" ? "idle" : s.status })); + }, [close]); + + const start = useCallback( + (message: string, mode: AiMode = "agent", continueRun = false) => { + if (!projectId || !message.trim()) return; + close(); // close previous stream if any + textBufferRef.current = []; + lastReqRef.current = { message, mode }; // store for "Continue" + + // Same-origin EventSource uses credentials; no Authorization header needed. + const baseUrl = API_URL; + const params = new URLSearchParams({ message, mode }); + if (tabId) params.set("tabId", tabId); + // "Continue": resume generation paused at step limit — backend sees existing + // graph and completes gaps (won't re-create existing ones). + if (continueRun) params.set("continue", "true"); + // Idempotency key — once per submission. EventSource auto-reconnect reopens + // the same URL (same requestId) → backend rejects duplicate generation + // (duplicate generation + duplicate nodes). + params.set("requestId", crypto.randomUUID()); + const url = `${baseUrl}/api/v1/projects/${projectId}/ai/chat/stream?${params.toString()}`; + + setState({ + status: "streaming", + mode, + progress: { nodes: 0, edges: 0 }, + accumulatedText: "", + message: null, + error: null, + retryable: false, + }); + + const es = new EventSource(url, { withCredentials: true }); + esRef.current = es; + activeStreams += 1; // close() decrements (done/paused/error/abort/unmount all go through close) + + es.addEventListener("node", (e) => { + const node = JSON.parse((e as MessageEvent).data) as BackendNode; + const member: TabGraphMember = { + id: node.id, + type: node.type, + properties: node.properties, + position: node.position, + version: node.version, + isReference: false, + }; + // UPSERT — agent can now update an existing node and re-emit the same id + // (refactor). Instead of append, replace if id exists (no duplicate). + let isNew = true; + qc.setQueryData(["tab-graph", projectId, tabId], (old) => { + if (!old) return old; + isNew = !old.nodes.some((n) => n.id === member.id); + return { + ...old, + nodes: isNew ? [...old.nodes, member] : old.nodes.map((n) => (n.id === member.id ? member : n)), + }; + }); + // Counter increments only on creation; an update mustn't inflate "N nodes created". + if (isNew) setState((s) => ({ ...s, progress: { ...s.progress, nodes: s.progress.nodes + 1 } })); + cbRef.current?.onNode?.(node.id); + }); + + es.addEventListener("edge", (e) => { + const edge = JSON.parse((e as MessageEvent).data) as BackendEdge; + const item: TabGraphEdge = { + id: edge.id, + kind: edge.kind, + sourceNodeId: edge.sourceNodeId, + targetNodeId: edge.targetNodeId, + }; + qc.setQueryData(["tab-graph", projectId, tabId], (old) => + old ? { ...old, edges: [...old.edges, item] } : old, + ); + setState((s) => ({ ...s, progress: { ...s.progress, edges: s.progress.edges + 1 } })); + cbRef.current?.onEdge?.(edge.id); + }); + + es.addEventListener("removed", (e) => { + // Terminal rollback — backend deleted orphan node; remove from cache. + // (On error path done doesn't invalidate; this listener is the only cleanup.) + const { id, kind } = JSON.parse((e as MessageEvent).data) as { id: string; kind: "node" | "edge" }; + qc.setQueryData(["tab-graph", projectId, tabId], (old) => { + if (!old) return old; + if (kind === "node") { + return { + ...old, + nodes: old.nodes.filter((n) => n.id !== id), + edges: old.edges.filter((ed) => ed.sourceNodeId !== id && ed.targetNodeId !== id), + }; + } + return { ...old, edges: old.edges.filter((ed) => ed.id !== id) }; + }); + cbRef.current?.onRemoved?.(id, kind); + }); + + es.addEventListener("text-delta", (e) => { + const { delta } = JSON.parse((e as MessageEvent).data) as { delta: string }; + textBufferRef.current.push(delta); + if (!flushScheduledRef.current) { + flushScheduledRef.current = true; + requestAnimationFrame(flushText); + } + }); + + es.addEventListener("done", (e) => { + const payload = JSON.parse((e as MessageEvent).data) as { message: string; counts?: { nodes: number; edges: number } }; + // Final flush — drain remaining buffer if any + if (textBufferRef.current.length > 0) flushText(); + // Align progress with backend truth — after orphan rollback 'removed' + // events don't decrement the counter, prevent inflated summary. + setState((s) => ({ ...s, status: "done", message: payload.message, progress: payload.counts ?? s.progress })); + close(); + if (mode === "agent") { + // Truth sync — nodes/edges created in agent mode, align cache with backend + qc.invalidateQueries({ queryKey: ["tab-graph", projectId, tabId] }); + qc.invalidateQueries({ queryKey: ["tabs", projectId] }); + } + // 4h quota counter consumed → refresh the remaining-allowance badge. + }); + + es.addEventListener("paused", (e) => { + // Step limit reached, work incomplete — orphans PRESERVED. Resumes with "Continue". + const payload = JSON.parse((e as MessageEvent).data) as { message: string; counts?: { nodes: number; edges: number } }; + if (textBufferRef.current.length > 0) flushText(); + setState((s) => ({ ...s, status: "paused", message: payload.message, progress: payload.counts ?? s.progress })); + close(); + // Partial generation written to DB → align cache with backend. + qc.invalidateQueries({ queryKey: ["tab-graph", projectId, tabId] }); + qc.invalidateQueries({ queryKey: ["tabs", projectId] }); + }); + + es.addEventListener("error", (e) => { + const data = (e as MessageEvent).data; + let errMsg = "AI connection lost."; + let code: string | undefined; + if (typeof data === "string") { + try { + const parsed = JSON.parse(data); + errMsg = parsed.message ?? errMsg; + code = parsed.code; + } catch { /* ignore */ } + } + // Duplicate connection (reconnect dedupe) — original stream continues, don't show error to user. + if (code === "ERR_DUPLICATE_REQUEST") { + close(); + return; + } + // Provider/timeout/connection error → retryable (retry via lastReqRef). + setState((s) => ({ ...s, status: "error", error: errMsg, retryable: true })); + close(); + }); + }, + [projectId, tabId, qc, close, flushText], + ); + + // "Continue" — reopen last request with continue=true (resumes where it left off). + const continueRun = useCallback(() => { + const { message, mode } = lastReqRef.current; + if (message) start(message, mode, true); + }, [start]); + + // "Try again" — after a provider/timeout error, run the last request from SCRATCH (not continue). + const retry = useCallback(() => { + const { message, mode } = lastReqRef.current; + if (message) start(message, mode, false); + }, [start]); + + useEffect(() => close, [close]); // unmount cleanup + + return { ...state, start, abort, continueRun, retry }; +} diff --git a/apps/web/src/api/api-keys.ts b/apps/web/src/api/api-keys.ts new file mode 100644 index 0000000..2e2b74c --- /dev/null +++ b/apps/web/src/api/api-keys.ts @@ -0,0 +1,55 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +/** API key metadata — the plaintext key is returned only once, in the create response. */ +export interface ApiKeyRecord { + id: string; + name: string; + prefix: string; + createdAt: string; + lastUsedAt: string | null; +} + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`/api/v1${path}`, { + credentials: "include", + ...init, + headers: { + ...(init?.body ? { "Content-Type": "application/json" } : {}), + ...init?.headers, + }, + }); + const body = await res.json(); + if (!res.ok) { + throw Object.assign(new Error(body?.error?.message ?? "Request failed"), { + code: body?.error?.code, + }); + } + return body.data as T; +} + +export function useApiKeys() { + return useQuery({ + queryKey: ["api-keys"], + queryFn: () => request<{ keys: ApiKeyRecord[] }>("/api-keys").then((d) => d.keys), + }); +} + +export function useCreateApiKey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => + request<{ key: string } & ApiKeyRecord>("/api-keys", { + method: "POST", + body: JSON.stringify({ name }), + }), + onSuccess: () => void qc.invalidateQueries({ queryKey: ["api-keys"] }), + }); +} + +export function useDeleteApiKey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => request<{ deleted: boolean }>(`/api-keys/${id}`, { method: "DELETE" }), + onSuccess: () => void qc.invalidateQueries({ queryKey: ["api-keys"] }), + }); +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..9154216 --- /dev/null +++ b/apps/web/src/api/client.ts @@ -0,0 +1,53 @@ +import createClient from "openapi-fetch"; +import type { paths } from "./schema"; +import { API_URL } from "../lib/env"; + +const BASE_URL = API_URL; + +/** Typed openapi-fetch client. OSS local mode: no auth headers (backend injects owner identity). */ +export const api = createClient({ baseUrl: BASE_URL, credentials: "include" }); + +/** Solarch envelope: { success, data } | { success:false, error }. */ +export interface ErrorEnvelope { + success: false; + error: { + code: string; + message: string; + details?: { field: string; issue: string }[]; + suggestion?: string; + ruleViolated?: string; + docLink?: string; + currentVersion?: number; + }; +} + +export class ApiError extends Error { + code: string; + details?: { field: string; issue: string }[]; + suggestion?: string; + constructor(code: string, message: string, details?: { field: string; issue: string }[], suggestion?: string) { + super(message); + this.name = "ApiError"; + this.code = code; + this.details = details; + this.suggestion = suggestion; + } +} + +export function unwrap(res: { data?: unknown; error?: unknown }): T { + if (res.error) { + const e = res.error as ErrorEnvelope; + if (e && e.error) throw new ApiError(e.error.code, e.error.message, e.error.details, e.error.suggestion); + throw new ApiError("ERR_UNKNOWN", "An unexpected error occurred."); + } + const body = res.data as { success: boolean; data: T }; + return body.data; +} + +export async function throwIfNotOk(res: Response): Promise { + if (res.ok) return; + let env: ErrorEnvelope | undefined; + try { env = (await res.json()) as ErrorEnvelope; } catch { /* no body */ } + const e = env?.error; + throw new ApiError(e?.code ?? "ERR_UNKNOWN", e?.message ?? `HTTP ${res.status}`, e?.details, e?.suggestion); +} diff --git a/apps/web/src/api/codegen.ts b/apps/web/src/api/codegen.ts new file mode 100644 index 0000000..a2cb42a --- /dev/null +++ b/apps/web/src/api/codegen.ts @@ -0,0 +1,386 @@ +/** Constructor codegen — backend generates a NestJS project skeleton from the node graph. + * schema.d.ts has no codegen path → typed api.POST is replaced by RAW fetch (raw.ts/client.ts). */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { throwIfNotOk } from "./client"; +import type { SystemMap } from "../features/simple/types"; +import { API_URL } from "../lib/env"; + +/** A single generated file. Matches the backend contract. */ +export interface GeneratedFile { + path: string; + content: string; + language: "typescript" | "sql" | "json" | "markdown" | "env"; + /** Number of Surgical AI markers (edit points) in this file. */ + surgicalMarkers: number; +} + +/** Full generation result. Matches the backend contract. */ +export interface GeneratedProject { + target: "nestjs"; + files: GeneratedFile[]; + /** Wire phase: which node mapped to which files (nodeId → path[]). + * The "Show Code" flow uses this to focus on the relevant node's first file. */ + nodeFiles: Record; + summary: { + fileCount: number; + nodeCount: number; + surgicalMarkerCount: number; + /** Node kinds that were not included in generation → count (e.g. { note: 2 }). */ + skippedKinds: Record; + }; +} + +/** Codegen mutation. Not called if projectId is missing (button only visible on project route). + * data → GeneratedProject (envelope.data). Error: throwIfNotOk → ApiError → global toast. + * On success the project's codegenVersion is stamped to CODEGEN_VERSION backend-side, so we + * invalidate ["codegen-status", projectId] → generated catches up to current → the TopBar + * "Codebase improved — Update" prompt disappears. */ +export function useGenerateCode(projectId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ( + input?: { target?: "nestjs" }, + ): Promise => { + const res = await fetch(`/api/v1/projects/${projectId}/codegen`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target: input?.target ?? "nestjs" }), + }); + await throwIfNotOk(res); + const body = (await res.json()) as { success: boolean; data: GeneratedProject }; + return body.data; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["codegen-status", projectId] }); + }, + }); +} + +/** Revert a filled surgical region back to its stub — deletes the saved (AI/human) body. + * After success the caller regenerates (useGenerateCode) so the region shows as a stub + * again. Idempotent on the backend. */ +export function useRevertFill(projectId: string) { + return useMutation({ + mutationFn: async (input: { nodeId: string; member: string }): Promise => { + const res = await fetch( + `/api/v1/projects/${projectId}/codegen/fill/${encodeURIComponent(input.nodeId)}/${encodeURIComponent(input.member)}`, + { + method: "DELETE", + credentials: "include", + }, + ); + await throwIfNotOk(res); + }, + }); +} + +/** Codegen freshness for a project. + * - current: the Constructor version this build of the backend produces. + * - generated: the Constructor version the project was last generated with + * (null → never generated). + * - updateAvailable: generated != null && generated < current — i.e. an older + * codebase exists and a better one can now be produced. (Never + * generated → false: there is nothing to "update", only first-time + * generation.) */ +export interface CodegenStatus { + current: number; + generated: number | null; + updateAvailable: boolean; + /** The project's current structural graph revision. */ + graphRevision: number; + /** Graph revision stamped at generation time; null if never generated. */ + generatedGraphRevision: number | null; + /** Has the diagram changed structurally since generation (generated code is behind). */ + diagramDrifted: boolean; + /** Number of structural changes since generation. */ + driftCount: number; +} + +/** Reads the codegen freshness status for a project (ProjectAccessGuard on backend). + * Powers the TopBar "Codebase improved — Update" prompt. Disabled when projectId + * is missing so it never fires on non-project routes. */ +export function useCodegenStatus(projectId: string | undefined) { + return useQuery({ + queryKey: ["codegen-status", projectId], + enabled: !!projectId, + queryFn: async (): Promise => { + const res = await fetch(`/api/v1/projects/${projectId}/codegen/status`, { + credentials: "include", + }); + await throwIfNotOk(res); + const body = (await res.json()) as { success: boolean; data: CodegenStatus }; + return body.data; + }, + }); +} + +/** Simple View — the non-dev projection of the technical graph (feature map + capabilities). + * Generated deterministically backend-side (sibling of the Mermaid export); free, no AI. */ +export function useSimpleView(projectId: string | undefined) { + return useQuery({ + queryKey: ["simple-view", projectId], + enabled: !!projectId, + queryFn: async (): Promise => { + const res = await fetch(`/api/v1/projects/${projectId}/codegen/simple-view`, { + credentials: "include", + }); + await throwIfNotOk(res); + const body = (await res.json()) as { success: boolean; data: SystemMap }; + return body.data; + }, + }); +} + +export interface SimpleSketch { mermaid: string; source: "ai" | "deterministic" } + +/** Mermaid for the hand-drawn Simple sketch (AI-refined + cached server-side). */ +export function useSimpleSketch(projectId: string | undefined) { + return useQuery({ + queryKey: ["simple-sketch", projectId], + enabled: !!projectId, + staleTime: 5 * 60_000, + queryFn: async (): Promise => { + const res = await fetch(`/api/v1/projects/${projectId}/codegen/simple-sketch`, { + credentials: "include", + }); + await throwIfNotOk(res); + const body = (await res.json()) as { success: boolean; data: SimpleSketch }; + return body.data; + }, + }); +} + +/** Structured, Mermaid-free Simple-View model (ELK-laid-out + rough-rendered client-side). */ +export type SketchNodeKind = "feature" | "action" | "data" | "decision" | "external" | "state"; +export interface SketchModelNode { id: string; kind: SketchNodeKind; name: string; group?: string; color?: string } +export interface SketchModelEdge { from: string; to: string; label?: string } +export interface SketchModelGroup { id: string; name: string; color?: string } +export interface SimpleSketchModel { nodes: SketchModelNode[]; edges: SketchModelEdge[]; groups: SketchModelGroup[] } +/** `source`: did the AI refine names/colors ('ai') or is this the plain deterministic structure? + * `aiConfigured`: is the AI configured at all (key present)? Lets the UI tell "AI off" apart from + * "AI configured but the refine fell back" (source='deterministic' while aiConfigured=true). */ +export interface SimpleSketchModelResp { model: SimpleSketchModel; source: "ai" | "deterministic"; aiConfigured: boolean } + +export function useSimpleSketchModel(projectId: string | undefined, stage?: "baseline") { + return useQuery({ + queryKey: ["simple-sketch-model", projectId, stage ?? "full"], + enabled: !!projectId, + // No client stale window: the server already caches in the DB (cheap to re-fetch), so always + // pull fresh on open — otherwise a stale browser copy keeps showing an OLD diagram after the + // server has regenerated. refetchOnMount: 'always' re-checks every time Simple View opens. + staleTime: 0, + refetchOnMount: "always", + queryFn: async (): Promise => { + const url = `/api/v1/projects/${projectId}/codegen/simple-sketch-model${stage ? `?stage=${stage}` : ""}`; + const res = await fetch(url, { + credentials: "include", + }); + await throwIfNotOk(res); + const body = (await res.json()) as { success: boolean; data: SimpleSketchModelResp }; + return body.data; + }, + }); +} + +/** Regenerate the Simple-View model — POSTs to bypass the server cache and re-run the AI refine, + * then writes the fresh result straight into the "full" query so the diagram updates in place. + * This is the "Regenerate" button: re-run the AI even when the graph hasn't changed. */ +export function useRegenerateSketchModel(projectId: string | undefined) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (): Promise => { + const res = await fetch(`/api/v1/projects/${projectId}/codegen/simple-sketch-model/regenerate`, { + method: "POST", + credentials: "include", + }); + await throwIfNotOk(res); + const body = (await res.json()) as { success: boolean; data: SimpleSketchModelResp }; + return body.data; + }, + onSuccess: (data) => { + qc.setQueryData(["simple-sketch-model", projectId, "full"], data); + }, + }); +} + +// ──────────────────────────────────────────────────────────── +// Surgical AI fill — SSE stream (server fills @solarch:surgical bodies) +// ──────────────────────────────────────────────────────────── + +export interface FillRegion { + status: "filled" | "violation" | "error"; + /** The region's node UUID — for the live "writing" animation (locates the file+nodeId+member region). */ + nodeId?: string; + member: string; + file: string; + attempts: number; + /** The filled body (when status="filled") — streamed into the editor with a typewriter effect. */ + body?: string; + /** WHY the region failed (status=violation/error): tsc/contract violations. Shown as + * "why" in the rail — the backend already carries this in the SSE region event. */ + violations?: string[]; + /** Error message if it could not be filled (error status). */ + error?: string; +} + +/** Verify/repair phase — project-wide progress (tsc/jest loop, live). + * `modgraph` = NestJS module-graph gate (boot-time DI: cycles / missing import-export) + * repaired deterministically; closes wiring errors that pass tsc but crash at boot. */ +export interface FillPhaseEntry { + kind: "verify" | "repair" | "imports" | "tests" | "modgraph"; + round?: number; + ok?: boolean; + errorCount?: number; + file?: string; + member?: string; + files?: number; + skipped?: boolean; + /** modgraph: number of deterministic repairs applied. */ + repairs?: number; + /** modgraph: remaining unrepairable findings (ideally 0). */ + findings?: number; +} + +/** Run mode: verified (tsc on the server) or draft (when the deps cache is absent). */ +export interface FillMode { + verified: boolean; + withTests: boolean; + reason?: string; +} + +/** Agent ACTIVITY (observation) — a single tool action of the fill agent (opencode-style live stream). + * Arrives from the backend SSE `activity` event. The summary is SAFE (NO code body / secret value). */ +export interface FillActivity { + member: string; + file: string; + tool: "read" | "grep" | "glob" | "lookup_members" | "verify_fill"; + summary: string; + ok?: boolean; + attempt?: number; +} + +export interface FillState { + status: "idle" | "streaming" | "done" | "error"; + fileCount: number; + markerCount: number; + /** Whether verified / jest enabled — filled when the `mode` event arrives. */ + mode: FillMode | null; + regions: FillRegion[]; + /** tsc/repair/test phase stream (live "watch the output"). */ + phases: FillPhaseEntry[]; + /** Agent activity stream (read/grep/verify_fill…) — opencode-style live watch. Capped. */ + activity: FillActivity[]; + filled: number; + violations: number; + errors: number; + /** Last tsc/test gate result (from the report event). */ + typecheck: { ok: boolean } | null; + tests: { ok: boolean; skipped?: boolean } | null; + /** Final filled project (the `files` event) — null until done. */ + files: GeneratedFile[] | null; + error: string | null; + /** Whether the error is transient/retryable (provider/timeout/ERR_FILL_UNVERIFIED) — show "Try + * again". Plan/quota errors (402) are NOT retryable (upgrade required). */ + retryable: boolean; +} + +const IDLE_FILL: FillState = { + status: "idle", fileCount: 0, markerCount: 0, mode: null, regions: [], phases: [], activity: [], + filled: 0, violations: 0, errors: 0, typecheck: null, tests: null, files: null, error: null, retryable: false, +}; + +/** Activity stream cap — a hard region calls many tools; keep only the last N so memory/render stay light. */ +const ACTIVITY_CAP = 300; + +/** Opens an EventSource to the Surgical AI fill stream. Accumulates per-region + * progress; on the terminal `files` event exposes the fully-filled project so the + * panel can swap the skeleton for the implemented code. Plan/quota denials (402) + * arrive as an SSE `error` event (never the global mutation toast) → handled here. */ +export function useFillStream(projectId: string | undefined) { + const qc = useQueryClient(); + const esRef = useRef(null); + const [state, setState] = useState(IDLE_FILL); + + const close = useCallback(() => { + if (esRef.current) { + esRef.current.close(); + esRef.current = null; + } + }, []); + + const start = useCallback((opts?: { jest?: boolean }) => { + if (!projectId) return; + close(); + setState({ ...IDLE_FILL, status: "streaming" }); + // jest ("deep verify") is optional: tsc is always in the loop; jest is slow → toggle. + const qs = opts?.jest ? "?jest=true" : ""; + const url = `${API_URL}/api/v1/projects/${projectId}/codegen/fill/stream${qs}`; + const es = new EventSource(url, { withCredentials: true }); + esRef.current = es; + + es.addEventListener("start", (e) => { + const d = JSON.parse((e as MessageEvent).data) as { fileCount: number; markerCount: number }; + setState((s) => ({ ...s, fileCount: d.fileCount, markerCount: d.markerCount })); + }); + es.addEventListener("mode", (e) => { + const d = JSON.parse((e as MessageEvent).data) as FillMode; + setState((s) => ({ ...s, mode: d })); + }); + es.addEventListener("phase", (e) => { + const d = JSON.parse((e as MessageEvent).data) as FillPhaseEntry; + setState((s) => ({ ...s, phases: [...s.phases, d] })); + }); + // The CLI's actual count of regions to fill (may differ from the marker count) — + // use this as the counter denominator; arrives AFTER `start` and finalizes markerCount. + es.addEventListener("begin", (e) => { + const d = JSON.parse((e as MessageEvent).data) as { total: number }; + setState((s) => ({ ...s, markerCount: d.total })); + }); + es.addEventListener("region", (e) => { + const d = JSON.parse((e as MessageEvent).data) as FillRegion; + setState((s) => ({ ...s, regions: [...s.regions, d] })); + }); + es.addEventListener("activity", (e) => { + const d = JSON.parse((e as MessageEvent).data) as FillActivity; + setState((s) => ({ ...s, activity: [...s.activity, d].slice(-ACTIVITY_CAP) })); + }); + es.addEventListener("report", (e) => { + const d = JSON.parse((e as MessageEvent).data) as { + filled: number; violations: number; errors: number; + typecheck?: { ok: boolean }; tests?: { ok: boolean; skipped?: boolean }; + }; + setState((s) => ({ + ...s, filled: d.filled, violations: d.violations, errors: d.errors, + typecheck: d.typecheck ?? null, tests: d.tests ?? null, + })); + }); + es.addEventListener("files", (e) => { + const d = JSON.parse((e as MessageEvent).data) as { files: GeneratedFile[] }; + setState((s) => ({ ...s, files: d.files, status: "done" })); + close(); + qc.invalidateQueries({ queryKey: ["codegen-status", projectId] }); + }); + es.addEventListener("error", (e) => { + const data = (e as MessageEvent).data; + let msg = "Surgical AI connection lost."; + if (typeof data === "string") { + try { + const p = JSON.parse(data); + msg = p.message ?? msg; + } catch { /* native close after done → no data */ } + } + // Native error after done → don't clobber done. + const retryable = true; + setState((s) => (s.status === "done" ? s : { ...s, status: "error", error: msg, retryable })); + close(); + }); + }, [projectId, qc, close]); + + const reset = useCallback(() => { close(); setState(IDLE_FILL); }, [close]); + + useEffect(() => close, [close]); // unmount cleanup + return { ...state, start, reset }; +} diff --git a/apps/web/src/api/edges.ts b/apps/web/src/api/edges.ts new file mode 100644 index 0000000..9b43f2a --- /dev/null +++ b/apps/web/src/api/edges.ts @@ -0,0 +1,43 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api, unwrap } from "./client"; +import { rawDeleteEdge } from "./raw"; + +export interface StoredEdge { + id: string; + projectId: string; + sourceNodeId: string; + targetNodeId: string; + kind: string; + properties: Record; + // non-blocking rules warning (e.g. WARN_COND_001 empty table) — returned on success path + warning?: { code: string; message: string; suggestion?: string }; +} + +export function useCreateEdge(projectId: string, tabId: string | null) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (input: { sourceNodeId: string; targetNodeId: string; kind: string }) => { + const res = await api.POST("/api/v1/projects/{projectId}/edges", { + params: { path: { projectId } }, + body: { + projectId, + sourceNodeId: input.sourceNodeId, + targetNodeId: input.targetNodeId, + kind: input.kind, + // IsAsync required; pub/sub edges are asynchronous. + properties: { IsAsync: ["PUBLISHES", "SUBSCRIBES"].includes(input.kind) }, + } as never, + }); + return unwrap(res); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["tab-graph", projectId, tabId] }), + }); +} + +export function useDeleteEdge(projectId: string) { + const qc = useQueryClient(); + // rawDeleteEdge: Bearer + credentials + throwIfNotOk (ApiError.code preserved) + invalidate. + return useMutation({ + mutationFn: (edgeId: string) => rawDeleteEdge(projectId, edgeId, qc), + }); +} diff --git a/apps/web/src/api/node-types.ts b/apps/web/src/api/node-types.ts new file mode 100644 index 0000000..a1a7393 --- /dev/null +++ b/apps/web/src/api/node-types.ts @@ -0,0 +1,41 @@ +import { useQuery } from "@tanstack/react-query"; +import { api, unwrap } from "./client"; + +export interface FieldHint { + badge?: string; + group?: string; + /** Value-set registry id (e.g. 'http-methods', 'parameter-types'). + * Frontend opens a Select widget — fetched from /value-sets/:id. */ + valueSet?: string; + /** Node reference within the project — frontend opens NodeRefCombobox. + * If edgeKind is present: source → target edge is auto-created after selection/create. */ + nodeRef?: { + type: string; + edgeKind?: string; + }; +} + +export interface NodeTypeDetail { + id: string; + family: string; + familyLabel: string; + description: string; + nameKey: string; + schema: unknown; // JSON Schema (OpenAPI export) + fieldHints: Record; // dotted path → group/badge metadata +} + +/** Full detail of a single node type — Zod → JSON Schema + fieldHints. For Inspector. */ +export function useNodeType(id: string | null) { + return useQuery({ + queryKey: ["node-type", id], + enabled: !!id, + staleTime: Infinity, // node type schema does not change at runtime + queryFn: async () => + unwrap( + await api.GET("/api/v1/node-types/{typeId}", { + params: { path: { typeId: id! } }, + }), + ), + }); +} diff --git a/apps/web/src/api/nodes.ts b/apps/web/src/api/nodes.ts new file mode 100644 index 0000000..19f14e1 --- /dev/null +++ b/apps/web/src/api/nodes.ts @@ -0,0 +1,133 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, unwrap } from "./client"; +import { rawDeleteNode } from "./raw"; + +export interface NodeTypeSummary { + id: string; + family: string; + familyLabel: string; + description: string; + nameKey: string; +} + +/** 21 node types — static, long cache. */ +export function useNodeTypes() { + return useQuery({ + queryKey: ["node-types"], + staleTime: Infinity, + queryFn: async () => { + const body = unwrap<{ types: NodeTypeSummary[]; total: number }>(await api.GET("/api/v1/node-types")); + return body.types; + }, + }); +} + +export function useCreateNode(projectId: string, tabId: string | null) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (input: { type: string; position: { x: number; y: number }; properties: Record }) => { + const res = await api.POST("/api/v1/projects/{projectId}/nodes", { + params: { path: { projectId } }, + body: { projectId, homeTabId: tabId ?? undefined, ...input } as never, + }); + return unwrap(res); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["tab-graph", projectId, tabId] }), + }); +} + +export interface StoredNode { + id: string; + projectId: string; + homeTabId: string; + type: string; + position: { x: number; y: number }; + properties: Record; + createdAt: string; + updatedAt: string; + version: number; // optimistic concurrency +} + +/** Single-node fetch for Inspector — runs when sidebar selection changes. */ +export function useNode(projectId: string, nodeId: string | null) { + return useQuery({ + queryKey: ["node", projectId, nodeId], + enabled: !!projectId && !!nodeId, + queryFn: async () => + unwrap( + await api.GET("/api/v1/projects/{projectId}/nodes/{nodeId}", { + params: { path: { projectId, nodeId: nodeId! } }, + }), + ), + }); +} + +/** Project-wide node list by type — data source for NodeRefCombobox autocomplete. + * Tab-agnostic: lists Exception/DTO/Enum etc. from all tabs. */ +export function useProjectNodes(projectId: string, type: string | null) { + return useQuery({ + queryKey: ["project-nodes", projectId, type], + enabled: !!projectId && !!type, + staleTime: 30_000, + queryFn: async () => { + const res = await api.GET("/api/v1/projects/{projectId}/nodes", { + params: { path: { projectId }, query: { type: type! } as never }, + }); + const body = unwrap<{ nodes: StoredNode[]; total: number }>(res); + return body.nodes; + }, + }); +} + +/** Node deletion — backend DELETE /projects/:pid/nodes/:nodeId, 204 No Content + DETACH (edge cascade). + * Uses raw fetch (path may be missing from openapi schema), error message extracted from envelope. + * Cache invalidate: all tab-graphs (homeTabId may differ from active tabId) + clear node cache. */ +export function useDeleteNode(projectId: string) { + const qc = useQueryClient(); + // rawDeleteNode: Bearer + credentials + throwIfNotOk (ApiError.code preserved) + + // tab-graph invalidate + node cache remove. Old manual fetch was giving 401 on + // cookie-race and losing the error code. + return useMutation({ + mutationFn: (nodeId: string) => rawDeleteNode(projectId, nodeId, qc), + }); +} + +/** Inspector PATCH — properties partial update. Called debounced for auto-save. + * Cache invalidate broader: all tab-graphs (homeTabId may differ from active tabId) + node cache. + * tabId param is now optional/legacy — unnecessary with broader invalidate. */ +export function useUpdateNode(projectId: string, nodeId: string | null, _tabId?: string | null) { + const qc = useQueryClient(); + void _tabId; + return useMutation({ + mutationFn: async (vars: { properties: Record; expectedVersion?: number }) => { + if (!nodeId) return undefined; + const res = await api.PATCH("/api/v1/projects/{projectId}/nodes/{nodeId}", { + params: { path: { projectId, nodeId } }, + // expectedVersion → backend optimistic concurrency guard (lost-update protection). + body: { properties: vars.properties, expectedVersion: vars.expectedVersion } as never, + }); + return unwrap(res); + }, + onSuccess: (result) => { + // Write new version to cache IMMEDIATELY → prevent stale-version false-conflict + // on consecutive autosaves (window before invalidate refetch). + if (result && nodeId) { + qc.setQueryData(["node", projectId, nodeId], (old) => + old ? { ...old, version: result.version } : old, + ); + } + qc.invalidateQueries({ queryKey: ["node", projectId, nodeId] }); + // tab-graph cache PREFIX match — canvas refreshes regardless of which tab the node is on + qc.invalidateQueries({ queryKey: ["tab-graph"] }); + }, + onError: (err) => { + // Version conflict → server state + fresh version is re-fetched; Inspector + // useEffect replaces draft with server data when serverProperties changes. + const code = (err as { code?: string } | null)?.code; + if (code === "ERR_VERSION_CONFLICT") { + qc.invalidateQueries({ queryKey: ["node", projectId, nodeId] }); + qc.invalidateQueries({ queryKey: ["tab-graph"] }); + } + }, + }); +} diff --git a/apps/web/src/api/openapi.ts b/apps/web/src/api/openapi.ts new file mode 100644 index 0000000..e62228c --- /dev/null +++ b/apps/web/src/api/openapi.ts @@ -0,0 +1,66 @@ +/** OpenAPI docs — the architecture graph projected to an OpenAPI 3.1 document the client renders + * with Scalar. Mirrors the Simple-View model hooks (useSimpleSketchModel / useRegenerateSketchModel): + * the GET serves the deterministic baseline instantly or the persisted AI-enriched ("documentized") + * doc; the POST forces a fresh grounded enrichment. */ + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { throwIfNotOk } from "./client"; + +/** Minimal structural view of the OpenAPI document (server type: `OpenAPIObject` from + * `@nestjs/swagger`). We only read `paths` (empty-state check) + `info`; Scalar consumes the + * whole object opaquely, so the rest stays loose. */ +export interface OpenApiDoc { + openapi: string; + info: { title: string; version: string; description?: string }; + paths: Record>; + components?: { schemas?: Record; securitySchemes?: Record }; + tags?: { name: string; description?: string }[]; +} + +/** `source`: did the AI annotate prose/examples ('ai') or is this the plain deterministic doc? + * `aiConfigured`: is the AI configured at all (key present)? Lets the UI tell "AI off" apart from + * "AI configured but the enrichment fell back" (source='deterministic' while aiConfigured=true). */ +export interface OpenApiResp { doc: OpenApiDoc; source: "ai" | "deterministic"; aiConfigured: boolean } + +/** The OpenAPI document for a project (ProjectAccessGuard on backend). + * `stage="baseline"` skips the AI for the instant deterministic doc. No client stale window: the + * server caches in the DB, so always pull fresh on open (refetchOnMount: 'always') — a stale browser + * copy would keep showing an OLD doc after the server regenerated. */ +export function useOpenApi(projectId: string | undefined, stage?: "baseline") { + return useQuery({ + queryKey: ["openapi", projectId, stage ?? "full"], + enabled: !!projectId, + staleTime: 0, + refetchOnMount: "always", + queryFn: async (): Promise => { + const url = `/api/v1/projects/${projectId}/codegen/openapi.json${stage ? `?stage=${stage}` : ""}`; + const res = await fetch(url, { + credentials: "include", + }); + await throwIfNotOk(res); + const body = (await res.json()) as { success: boolean; data: OpenApiResp }; + return body.data; + }, + }); +} + +/** AI Documentize — POSTs to bypass the server cache and re-run the grounded enrichment, then writes + * the fresh result straight into the "full" query so the rendered docs update in place. The structure + * stays graph-true; only descriptions/examples on existing operations/schemas change. */ +export function useDocumentize(projectId: string | undefined) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (): Promise => { + const res = await fetch(`/api/v1/projects/${projectId}/codegen/openapi/documentize`, { + method: "POST", + credentials: "include", + }); + await throwIfNotOk(res); + const body = (await res.json()) as { success: boolean; data: OpenApiResp }; + return body.data; + }, + onSuccess: (data) => { + qc.setQueryData(["openapi", projectId, "full"], data); + }, + }); +} diff --git a/apps/web/src/api/patterns.ts b/apps/web/src/api/patterns.ts new file mode 100644 index 0000000..645c5be --- /dev/null +++ b/apps/web/src/api/patterns.ts @@ -0,0 +1,99 @@ +/** Pattern gallery — canonical seed patterns as starting points. + * GET /patterns (read-only, seed) drives the Welcome zero-state. Creating from a + * pattern is: create project → fetch the pattern's graph → atomic graph/apply. + * Raw fetch + envelope (same pattern as codegen.ts / review.ts). */ + +import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { throwIfNotOk } from "./client"; + +export interface PatternSummary { + id: string; + name: string; + description: string; + tags: string[]; + source: string; + createdAt: string; + nodeCount: number; + edgeCount: number; +} + +export interface PatternGraph { + nodes: { tempId: string; type: string; properties: Record }[]; + edges: { sourceTempId: string; targetTempId: string; edgeType: string; label?: string }[]; +} + +export interface StoredPattern extends PatternSummary { + graph: PatternGraph; +} + +async function authHeaders(): Promise> { + return { + "Content-Type": "application/json", + }; +} + +/** Canonical seed patterns (read-only). */ +export function usePatterns() { + return useQuery({ + queryKey: ["patterns"], + queryFn: async (): Promise => { + const res = await fetch("/api/v1/patterns", { credentials: "include", headers: await authHeaders() }); + await throwIfNotOk(res); + const body = (await res.json()) as { success: boolean; data: PatternSummary[] }; + return body.data; + }, + }); +} + +/** Full pattern graphs (for the gallery previews) — one query per id, cached + * forever (seed patterns are immutable). Fetched lazily when the sheet opens. */ +export function usePatternDetails(ids: string[]) { + return useQueries({ + queries: ids.map((id) => ({ + queryKey: ["pattern", id], + staleTime: Infinity, + queryFn: async (): Promise => { + const res = await fetch(`/api/v1/patterns/${id}`, { credentials: "include", headers: await authHeaders() }); + await throwIfNotOk(res); + return ((await res.json()) as { data: StoredPattern }).data; + }, + })), + }); +} + +/** Create a new project and seed it with a pattern's (rules-legal) sub-graph. */ +export function useCreateFromPattern() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ name, patternId }: { name: string; patternId: string }): Promise<{ id: string }> => { + const headers = await authHeaders(); + + // 1. Create the project. + const pres = await fetch("/api/v1/projects", { + method: "POST", + credentials: "include", + headers, + body: JSON.stringify({ name }), + }); + await throwIfNotOk(pres); + const project = ((await pres.json()) as { data: { id: string } }).data; + + // 2. Fetch the pattern's full graph (apply wire format). + const gres = await fetch(`/api/v1/patterns/${patternId}`, { credentials: "include", headers }); + await throwIfNotOk(gres); + const pattern = ((await gres.json()) as { data: StoredPattern }).data; + + // 3. Atomically apply the sub-graph to the new project. + const ares = await fetch(`/api/v1/projects/${project.id}/graph/apply`, { + method: "POST", + credentials: "include", + headers, + body: JSON.stringify({ mutations: { nodes: pattern.graph.nodes, edges: pattern.graph.edges } }), + }); + await throwIfNotOk(ares); + + return { id: project.id }; + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["projects"] }), + }); +} diff --git a/apps/web/src/api/projects.ts b/apps/web/src/api/projects.ts new file mode 100644 index 0000000..33b7b41 --- /dev/null +++ b/apps/web/src/api/projects.ts @@ -0,0 +1,43 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, unwrap } from "./client"; + +export interface ProjectSummary { + id: string; + name: string; + description: string; + status: "draft" | "active" | "archived"; + createdAt: string; + updatedAt: string; + counts: { nodes: number; edges: number }; +} + +export function useProjects() { + return useQuery({ + queryKey: ["projects"], + queryFn: async () => { + // list endpoint returns { projects, total } → extract the projects array. + const body = unwrap<{ projects: ProjectSummary[]; total: number }>(await api.GET("/api/v1/projects")); + return body.projects; + }, + }); +} + +export function useCreateProject() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (name: string) => + unwrap(await api.POST("/api/v1/projects", { body: { name } as never })), + onSuccess: () => qc.invalidateQueries({ queryKey: ["projects"] }), + }); +} + +export function useDeleteProject() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + const res = await api.DELETE("/api/v1/projects/{projectId}", { params: { path: { projectId: id } } }); + if (res.error) throw new Error("Could not delete"); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["projects"] }), + }); +} diff --git a/apps/web/src/api/raw.ts b/apps/web/src/api/raw.ts new file mode 100644 index 0000000..d3b09c6 --- /dev/null +++ b/apps/web/src/api/raw.ts @@ -0,0 +1,91 @@ +/** Raw API calls outside hooks for undo/redo + autosave-flush. */ + +import type { QueryClient } from "@tanstack/react-query"; +import { throwIfNotOk } from "./client"; + +function jsonHeaders(): Record { + return { "Content-Type": "application/json" }; +} + +export const rawUpdateNodeProps = async ( + projectId: string, + nodeId: string, + properties: Record, + qc: QueryClient, + expectedVersion?: number, +) => { + const res = await fetch(`/api/v1/projects/${projectId}/nodes/${nodeId}`, { + method: "PATCH", + headers: jsonHeaders(), + credentials: "include", + body: JSON.stringify({ properties, expectedVersion }), + }); + await throwIfNotOk(res); + qc.invalidateQueries({ queryKey: ["tab-graph"] }); + qc.invalidateQueries({ queryKey: ["node", projectId, nodeId] }); +}; + +export const rawDeleteNode = async ( + projectId: string, + nodeId: string, + qc: QueryClient, +) => { + const res = await fetch(`/api/v1/projects/${projectId}/nodes/${nodeId}`, { + method: "DELETE", + credentials: "include", + }); + await throwIfNotOk(res); + qc.invalidateQueries({ queryKey: ["tab-graph"] }); + qc.removeQueries({ queryKey: ["node", projectId, nodeId] }); +}; + +export const rawCreateNode = async ( + projectId: string, + input: { type: string; homeTabId?: string; position: { x: number; y: number }; properties: Record }, + qc: QueryClient, +): Promise<{ id: string }> => { + const res = await fetch(`/api/v1/projects/${projectId}/nodes`, { + method: "POST", + headers: jsonHeaders(), + credentials: "include", + body: JSON.stringify({ projectId, ...input }), + }); + await throwIfNotOk(res); + const body = await res.json(); + qc.invalidateQueries({ queryKey: ["tab-graph"] }); + return body?.data ?? body; +}; + +export const rawCreateEdge = async ( + projectId: string, + input: { sourceNodeId: string; targetNodeId: string; kind: string }, + qc: QueryClient, +): Promise<{ id: string; warning?: { code: string; message: string; suggestion?: string } }> => { + const res = await fetch(`/api/v1/projects/${projectId}/edges`, { + method: "POST", + headers: jsonHeaders(), + credentials: "include", + body: JSON.stringify({ + projectId, + ...input, + properties: { IsAsync: ["PUBLISHES", "SUBSCRIBES"].includes(input.kind) }, + }), + }); + await throwIfNotOk(res); + const body = await res.json(); + qc.invalidateQueries({ queryKey: ["tab-graph"] }); + return body?.data ?? body; +}; + +export const rawDeleteEdge = async ( + projectId: string, + edgeId: string, + qc: QueryClient, +) => { + const res = await fetch(`/api/v1/projects/${projectId}/edges/${edgeId}`, { + method: "DELETE", + credentials: "include", + }); + await throwIfNotOk(res); + qc.invalidateQueries({ queryKey: ["tab-graph"] }); +}; diff --git a/apps/web/src/api/review.ts b/apps/web/src/api/review.ts new file mode 100644 index 0000000..1370fce --- /dev/null +++ b/apps/web/src/api/review.ts @@ -0,0 +1,42 @@ +/** "Verify my architecture" — whole-graph rule review. + * POST /projects/:id/review re-evaluates every edge through the Rules Engine and + * returns a ranked Problems list. Deterministic (no LLM); same fetch+envelope + * pattern as codegen.ts. */ + +import { useMutation } from "@tanstack/react-query"; +import { throwIfNotOk } from "./client"; + +export interface ReviewFinding { + severity: "error" | "warning"; + code: string; + message: string; + suggestion?: string; + ruleViolated?: string; + docLink?: string; + edgeId: string; + edgeKind: string; + /** [sourceId, targetId] — for focusEdge/focusNode. */ + nodeIds: string[]; +} + +export interface ReviewResult { + findings: ReviewFinding[]; + summary: { total: number; errors: number; warnings: number; clean: boolean }; +} + +export function useReviewArchitecture(projectId: string) { + return useMutation({ + mutationFn: async (): Promise => { + const res = await fetch(`/api/v1/projects/${projectId}/review`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + }); + await throwIfNotOk(res); + const body = (await res.json()) as { success: boolean; data: ReviewResult }; + return body.data; + }, + }); +} diff --git a/apps/web/src/api/rules.ts b/apps/web/src/api/rules.ts new file mode 100644 index 0000000..8e61a82 --- /dev/null +++ b/apps/web/src/api/rules.ts @@ -0,0 +1,77 @@ +import { useQuery } from "@tanstack/react-query"; +import { api, unwrap } from "./client"; + +export interface WhitelistRule { + source: string | string[]; + edge: string; + target: string | string[]; + layer?: string; + note?: string; +} + +/** Full rule matrix (whitelist) — static, long cache. */ +export function useRules() { + return useQuery({ + queryKey: ["rules"], + staleTime: Infinity, + queryFn: async () => { + const body = unwrap<{ whitelist: WhitelistRule[] }>(await api.GET("/api/v1/rules")); + return body.whitelist; + }, + }); +} + +const asArr = (v: string | string[]) => (Array.isArray(v) ? v : [v]); + +/** Allowed edge types for source → target (from the Rules Engine whitelist). */ +export function legalEdgeKinds(whitelist: WhitelistRule[], src: string, tgt: string): { edge: string; note?: string }[] { + const seen = new Set(); + const out: { edge: string; note?: string }[] = []; + for (const r of whitelist) { + if (asArr(r.source).includes(src) && asArr(r.target).includes(tgt) && !seen.has(r.edge)) { + seen.add(r.edge); + out.push({ edge: r.edge, note: r.note }); + } + } + return out; +} + +/** All (source type, edge kind) pairs that can connect to the target type — for input port drag. */ +export function legalSources( + whitelist: WhitelistRule[], + tgt: string, +): { nodeType: string; edge: string; note?: string }[] { + const seen = new Set(); + const out: { nodeType: string; edge: string; note?: string }[] = []; + for (const r of whitelist) { + if (!asArr(r.target).includes(tgt)) continue; + for (const s of asArr(r.source)) { + const key = `${s}::${r.edge}`; + if (!seen.has(key)) { + seen.add(key); + out.push({ nodeType: s, edge: r.edge, note: r.note }); + } + } + } + return out; +} + +/** All (target type, edge kind) pairs that can originate from the source type — for QuickConnectMenu. */ +export function legalTargets( + whitelist: WhitelistRule[], + src: string, +): { nodeType: string; edge: string; note?: string }[] { + const seen = new Set(); + const out: { nodeType: string; edge: string; note?: string }[] = []; + for (const r of whitelist) { + if (!asArr(r.source).includes(src)) continue; + for (const t of asArr(r.target)) { + const key = `${t}::${r.edge}`; + if (!seen.has(key)) { + seen.add(key); + out.push({ nodeType: t, edge: r.edge, note: r.note }); + } + } + } + return out; +} diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts new file mode 100644 index 0000000..25e7024 --- /dev/null +++ b/apps/web/src/api/schema.d.ts @@ -0,0 +1,2642 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/v1/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Health check + * @description Verifies that the service is up and running. Returns `{ status: 'ok', uptime }`. Used for liveness/readiness probes. + */ + get: operations["HealthController_check"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List projects + * @description Returns all projects (newest first). Includes `counts` (node + edge count) for each project. + */ + get: operations["ProjectsController_list"]; + put?: never; + /** + * Create new project + * @description Creates a new architecture project (workspace). The project must exist before adding nodes/edges. If `id` is not provided, the server generates a UUID. If `status` is not provided, defaults to `draft`. + */ + post: operations["ProjectsController_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{projectId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Single project (+ counts) + * @description Returns the specified project with node/edge `counts`. + */ + get: operations["ProjectsController_getById"]; + put?: never; + post?: never; + /** + * Delete project (cascade) + * @description Permanently deletes the project **and all its nodes + edges** (cascade). Cannot be undone. + */ + delete: operations["ProjectsController_delete"]; + options?: never; + head?: never; + /** + * Update project + * @description Only `name`, `description`, and `status` can be updated (partial). `id` and timestamps are immutable. + */ + patch: operations["ProjectsController_update"]; + trace?: never; + }; + "/api/v1/projects/{projectId}/graph": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Full project graph + * @description Returns **all nodes + edges of the project in a single request**: `{ project, nodes[], edges[], counts }`. Ideal for loading the frontend canvas. + */ + get: operations["ProjectsController_getGraph"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{projectId}/tabs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List tabs + * @description Sorted by order. + */ + get: operations["TabsController_list"]; + put?: never; + /** + * Create tab + * @description New context/canvas tab. moduleNodeId is optional (drill-down source). + */ + post: operations["TabsController_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{projectId}/tabs/{tabId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Tab detail */ + get: operations["TabsController_getById"]; + put?: never; + post?: never; + /** + * Delete tab + * @description Default tab cannot be deleted. Owned nodes are moved to Main Architecture, references are removed. + */ + delete: operations["TabsController_delete"]; + options?: never; + head?: never; + /** Update tab (name/order) */ + patch: operations["TabsController_update"]; + trace?: never; + }; + "/api/v1/projects/{projectId}/tabs/{tabId}/graph": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Tab render content + * @description Owned + referenced nodes (position + origin) + edges between them. + */ + get: operations["TabsController_graph"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{projectId}/tabs/{tabId}/references/{nodeId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Import node to tab / update reference position */ + put: operations["TabsController_addReference"]; + post?: never; + /** Remove reference (node is not deleted) */ + delete: operations["TabsController_removeReference"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{projectId}/tabs/{tabId}/layout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Bulk save positions + * @description After drag: owned → node position, referenced → reference position. + */ + patch: operations["TabsController_layout"]; + trace?: never; + }; + "/api/v1/projects/{projectId}/nodes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List nodes + * @description Returns nodes in the project. Can be filtered to a single kind with the `?type=Table` query parameter. + */ + get: operations["NodesController_list"]; + put?: never; + /** + * Create new node + * @description Adds a new building block to the project. The body's **kind-specific `properties`** are validated with Zod based on the `type` (kind) discriminator. If `id`/timestamp is not provided, the server generates them. Project must exist (strict integrity), `*Name` must be unique within the project. + */ + post: operations["NodesController_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{projectId}/nodes/{nodeId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Single node + * @description Returns the specified node with its full properties. + */ + get: operations["NodesController_getById"]; + put?: never; + post?: never; + /** + * Delete node + * @description Deletes the node and its connected edges (DETACH). Not idempotent — returns 404 if not found. + */ + delete: operations["NodesController_delete"]; + options?: never; + head?: never; + /** + * Update node (field-level replace) + * @description `position` and/or `properties` are replaced with the provided full object (no deep merge). `type` (kind) **cannot be changed** — attempting it returns `ERR_KIND_IMMUTABLE`. + */ + patch: operations["NodesController_update"]; + trace?: never; + }; + "/api/v1/node-types": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List all node types + * @description Returns a summary of 21 node types: `id`, `family` (Data/Business/Access/...), `nameKey` (unique field within project) and a short description. Populate the frontend's 'Add New Node' menu from this endpoint. + */ + get: operations["NodeTypesController_listAll"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/node-types/{typeId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Single node type (+ JSON Schema) + * @description The specified node type's metadata + **full JSON Schema** (generated with zodV3ToOpenAPI). The frontend renders its dynamic form from this schema. + */ + get: operations["NodeTypesController_getById"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/node-types/{typeId}/rule": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Architecture rules for this node type + * @description This node type's role in the Rules Engine: which connections it is **allowed as source/target** (`allowAsSource`/`allowAsTarget`) and which are **denied** (`denyAsSource`/`denyAsTarget`, with ERR codes). + */ + get: operations["NodeTypesController_getRules"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/rules": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Architecture rule catalog + * @description Returns all rules from the Rules Engine: + * + * - **whitelist** (~32 rules): allowed source→edge→target combinations, split into 6 layers + * - **blacklist** (7 rules): strict denials `ERR_001..ERR_007` (message + suggestion) + * - **conditional** (3 rules): cyclic dependency, type mismatch, empty schema + * - **defaults**: unspecified connections are `deny` (default deny) + * + * Consult this catalog to learn which connections can be established between two nodes. + */ + get: operations["RulesController_catalog"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{projectId}/edges": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List edges + * @description Returns edges in the project. Can be filtered with `?kind=CALLS`, `?sourceNodeId=...`, `?targetNodeId=...` (combinable). + */ + get: operations["EdgesController_list"]; + put?: never; + /** + * Create new edge (Rules Engine protected) + * @description Creates a directed connection between two nodes. The **Rules Engine** is enforced: connections not on the whitelist or caught by the blacklist are rejected (409). Self-loops and duplicates are also blocked. Source/target nodes + project must exist. + */ + post: operations["EdgesController_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{projectId}/edges/{edgeId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Single edge + * @description Returns the specified edge with source/target/kind/properties. + */ + get: operations["EdgesController_getById"]; + put?: never; + post?: never; + /** + * Delete edge + * @description Deletes the connection. Nodes are not affected. + */ + delete: operations["EdgesController_delete"]; + options?: never; + head?: never; + /** + * Update edge (properties only) + * @description Only `properties` (Label/IsAsync/Protocol/RetryCount) can be updated. `kind`/`sourceNodeId`/`targetNodeId` **cannot be changed** — `ERR_EDGE_IMMUTABLE`. To change the connection, delete and recreate. + */ + patch: operations["EdgesController_update"]; + trace?: never; + }; + "/api/v1/projects/{projectId}/edges/validate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Pre-validate connection (does not write to DB) + * @description Runs the Rules Engine check **before** creating an edge — nothing is written to the DB. Called in the UI when the user draws an arrow or before the AI establishes a connection. Returns `{ isValid, severity?, engineResult? }`; `engineResult.suggestion` offers a fix recommendation. + */ + post: operations["EdgesController_validate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/edge-types": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List all edge types + * @description Returns 16 connection types: `id`, `family` (Communication/Data/Infrastructure/Architecture), description, example `source`/`target`, and direction note. The UI edge type picker is populated from this endpoint. + */ + get: operations["EdgeTypesController_listAll"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/edge-types/{edgeKind}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Single edge type + * @description Detail of the specified edge type: family, description, example source/target, direction note. + */ + get: operations["EdgeTypesController_getById"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/edge-types/{edgeKind}/rule": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Architecture rules for this edge type + * @description Returns the **allowed** (`allow` — which source→target pairs) and **denied** (`deny` — with ERR codes) rules for this edge type. + */ + get: operations["EdgeTypesController_getRules"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{projectId}/graph/apply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk apply architecture graph (AI batch) + * @description Processes multiple nodes + edges in a **single atomic transaction**. Used by the AI agent or frontend for bulk-save. + * + * Each node is validated with Zod, each edge with the Rules Engine + intra-batch cyclic dependency check. **Any violation rolls back the entire batch** (ROLLED_BACK) and returns `violations[]` + `suggestion`s — the AI reads these and self-corrects. + * + * Edges reference nodes by `tempId`; on success returns `idMap { tempId → permanent UUID }`. Positions are assigned server-side via auto-grid. + */ + post: operations["GraphController_apply"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{projectId}/ai/chat": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Chat with AI architect (generate architecture) + * @description Forwards a natural language request to the 'Chief Software Architect' AI. The AI sees the current graph (current_graph), generates architecture via the `apply_architecture_graph` tool, and **self-corrects** on Rules Engine violations (ReAct self-correction, max 3 attempts). Generation: Bedrock/Claude, tool calling. + * + * Response: `{ reply, applied?: {idMap, nodeCount, edgeCount}, attempts }`. If `applied` is populated, the architecture has been added to the canvas. + */ + post: operations["AiController_chat"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/patterns": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List patterns + * @description Summary of all patterns (including node/edge counts). + */ + get: operations["PatternsController_list"]; + put?: never; + /** + * Create pattern + * @description Reusable architecture sub-graph + description. The description is embedded and written to the vector index. + */ + post: operations["PatternsController_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/patterns/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Single pattern + * @description Full pattern including graphJson (sub-graph). + */ + get: operations["PatternsController_getById"]; + put?: never; + post?: never; + /** Delete pattern */ + delete: operations["PatternsController_delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/patterns/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Semantic pattern search + * @description Embeds the query and returns top-K cosine similarity from the native vector index. Returns empty list if no embeddings exist. + */ + post: operations["PatternsController_search"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{projectId}/patterns/promote": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Promote pattern from project graph + * @description Adds an existing project's graph (or a selected nodeIds sub-graph) to the pattern library. + */ + post: operations["PatternsController_promote"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: never; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + HealthController_check: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description `data: { status: 'ok', uptime: }`. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ProjectsController_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description `data: { projects: [...], total }`. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ProjectsController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + /** @default */ + description?: string; + /** + * @default draft + * @enum {string} + */ + status?: "draft" | "active" | "archived"; + }; + }; + }; + responses: { + /** @description Project created. `data` returns the project + `counts: {nodes:0, edges:0}`. */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_SCHEMA_INVALID` — name/description missing or invalid status. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_ID_CONFLICT` — the provided `id` is already in use. */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ProjectsController_getById: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Project + counts. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_PROJECT_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ProjectsController_delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted (no body). */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_PROJECT_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ProjectsController_update: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name?: string; + description?: string; + /** @enum {string} */ + status?: "draft" | "active" | "archived"; + }; + }; + }; + responses: { + /** @description Updated project + counts. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_SCHEMA_INVALID`. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_PROJECT_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ProjectsController_getGraph: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Full graph: project + nodes + edges + counts. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_PROJECT_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + TabsController_list: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + TabsController_create: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + /** Format: uuid */ + moduleNodeId?: string; + }; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + TabsController_getById: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + tabId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description ERR_TAB_NOT_FOUND */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + TabsController_delete: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + tabId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description ERR_TAB_DEFAULT_DELETE */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + TabsController_update: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + tabId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name?: string; + order?: number; + }; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + TabsController_graph: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + tabId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + TabsController_addReference: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + tabId: string; + nodeId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + x: number; + y: number; + }; + }; + }; + responses: { + /** @description ERR_TAB_SELF_REFERENCE */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + TabsController_removeReference: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + tabId: string; + nodeId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + TabsController_layout: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + tabId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + items: { + /** Format: uuid */ + nodeId: string; + x: number; + y: number; + }[]; + }; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + NodesController_list: { + parameters: { + query?: { + /** @description Node kind filter (e.g. `Table`, `Service`). Invalid values are ignored. */ + type?: unknown; + }; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description `data: { nodes: [...], total }`. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + NodesController_create: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID that the node belongs to */ + projectId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "Table"; + properties: { + TableName: string; + Description: string; + Columns: { + /** @description Column name */ + Name: string; + /** + * @description SQL data type + * @enum {string} + */ + DataType: "INT" | "BIGINT" | "VARCHAR" | "TEXT" | "BOOLEAN" | "DATETIME" | "DATE" | "UUID" | "FLOAT" | "DECIMAL" | "JSON" | "ENUM"; + /** @description VARCHAR(n) length */ + Length?: number; + /** @description DECIMAL(p,s) precision */ + Precision?: number; + /** @description DECIMAL(p,s) scale */ + Scale?: number; + /** @description Single-column PK */ + IsPrimaryKey: boolean; + /** @description NOT NULL */ + IsNotNull: boolean; + /** @description UNIQUE */ + IsUnique: boolean; + /** @description AUTO_INCREMENT / SERIAL */ + AutoIncrement: boolean; + /** @description Default value expression */ + DefaultValue?: string; + /** @description Column comment */ + Comment?: string; + /** @description If DataType=ENUM → Enum node Name */ + EnumRef?: string; + /** @description GENERATED column */ + IsGenerated?: boolean; + /** @description Generated column expression */ + GeneratedExpression?: string; + }[]; + /** @description Composite PK (use Column.IsPrimaryKey for single-column) */ + PrimaryKey?: { + Columns: string[]; + }; + /** @default [] */ + ForeignKeys?: { + Name?: string; + Columns: string[]; + /** @description Target Table Name */ + ReferencesTable: string; + ReferencesColumns: string[]; + /** + * @default NO_ACTION + * @enum {string} + */ + OnDelete?: "CASCADE" | "RESTRICT" | "SET_NULL" | "NO_ACTION"; + /** + * @default NO_ACTION + * @enum {string} + */ + OnUpdate?: "CASCADE" | "RESTRICT" | "SET_NULL" | "NO_ACTION"; + }[]; + /** @default [] */ + UniqueConstraints?: { + Name?: string; + Columns: string[]; + }[]; + /** @default [] */ + CheckConstraints?: { + Name?: string; + Expression: string; + }[]; + /** @default [] */ + Indexes?: { + IndexName: string; + Columns: string[]; + /** + * @default BTree + * @enum {string} + */ + Type?: "BTree" | "Hash" | "GIN" | "GiST"; + /** @default false */ + IsUnique?: boolean; + IsPartial?: boolean; + WhereClause?: string; + }[]; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "DTO"; + properties: { + Name: string; + Description: string; + Fields: { + Name: string; + DataType: string; + IsRequired: boolean; + IsArray: boolean; + /** @default [] */ + ValidationRules?: { + /** @enum {string} */ + Rule: "Min" | "Max" | "MinLength" | "MaxLength" | "Email" | "Url" | "Regex" | "Pattern" | "Positive" | "Negative"; + /** @description Min/Max/Length value or Regex pattern */ + Value?: string; + }[]; + DefaultValue?: string; + /** @description → DTO node Name (nested DTO) */ + NestedDTORef?: string; + /** @description → Enum node Name */ + EnumRef?: string; + Description?: string; + }[]; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "Model"; + properties: { + ClassName: string; + Description: string; + /** @description → Table node TableName ref */ + TableRef?: string; + Properties: { + Name: string; + Type: string; + /** @default false */ + IsNullable?: boolean; + /** @default false */ + IsCollection?: boolean; + /** @enum {string} */ + RelationType?: "OneToOne" | "OneToMany" | "ManyToOne" | "ManyToMany"; + /** @description → Model node ClassName ref */ + RelatedModelRef?: string; + }[]; + /** @default [] */ + Methods?: { + MethodName: string; + /** + * @default public + * @enum {string} + */ + Visibility?: "public" | "private" | "protected"; + /** @default [] */ + Parameters?: { + Name: string; + Type: string; + /** @default false */ + Optional?: boolean; + Default?: string; + }[]; + ReturnType: string; + /** @default false */ + IsAsync?: boolean; + /** @default false */ + IsStatic?: boolean; + }[]; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "Enum"; + properties: { + Name: string; + Description: string; + /** + * @default string + * @enum {string} + */ + BackingType?: "string" | "int"; + Values: { + Key: string; + /** @description Backing value (Key is used if not provided) */ + Value?: string; + Description?: string; + }[]; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "View"; + properties: { + ViewName: string; + Description: string; + /** @description SQL/aggregate definition */ + Definition: string; + /** @description → Table Names */ + SourceTables: string[]; + Materialized: boolean; + /** @default [] */ + Columns?: { + Name: string; + DataType: string; + }[]; + /** + * @description Refresh strategy for materialized view + * @enum {string} + */ + RefreshStrategy?: "onDemand" | "scheduled" | "onChange"; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "Service"; + properties: { + ServiceName: string; + Description: string; + IsTransactionScoped: boolean; + Methods: { + MethodName: string; + /** + * @default public + * @enum {string} + */ + Visibility?: "public" | "private" | "protected"; + /** @default [] */ + Parameters?: { + Name: string; + Type: string; + /** @default false */ + Optional?: boolean; + Default?: string; + /** @description If parameter type is a DTO → DTO node Name */ + DtoRef?: string; + }[]; + ReturnType: string; + /** @description If return type is a DTO → DTO node Name */ + ReturnDtoRef?: string; + /** @default false */ + IsAsync?: boolean; + /** + * @description Throwable → Exception node Names + * @default [] + */ + Throws?: string[]; + Description?: string; + }[]; + /** @default [] */ + Dependencies?: { + /** @enum {string} */ + Kind: "Repository" | "Service" | "Cache" | "ExternalService"; + /** @description Dependent node's Name (DI) */ + Ref: string; + }[]; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "Worker"; + properties: { + WorkerName: string; + Description: string; + /** @description Cron expression */ + Schedule: string; + TaskToExecute: string; + TimeoutSeconds: number; + RetryPolicy: { + MaxRetries: number; + /** @enum {string} */ + BackoffStrategy?: "fixed" | "exponential"; + DelaySeconds?: number; + }; + Concurrency?: number; + /** @default true */ + IsEnabled?: boolean; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "EventHandler"; + properties: { + HandlerName: string; + Description: string; + EventName: string; + IsAsync: boolean; + /** @description Listens to → MessageQueue node Name */ + QueueRef?: string; + RetryPolicy?: { + MaxRetries: number; + DelaySeconds?: number; + }; + DeadLetterQueue?: string; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "Controller"; + properties: { + ControllerName: string; + Description: string; + BaseRoute: string; + /** @description API version, e.g. 'v1' */ + Version?: string; + Endpoints: { + /** @enum {string} */ + HttpMethod: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + Route: string; + /** @description → DTO node Name */ + RequestDTORef?: string; + /** @description → DTO node Name */ + ResponseDTORef?: string; + RequiresAuth: boolean; + /** @default [] */ + RequiredRoles?: string[]; + /** @default [] */ + PathParams?: { + Name: string; + Type: string; + }[]; + /** @default [] */ + QueryParams?: { + Name: string; + Type: string; + /** @default false */ + Required?: boolean; + }[]; + /** @default [] */ + StatusCodes?: { + Code: number; + Description?: string; + }[]; + /** + * @description → Middleware node Names + * @default [] + */ + MiddlewareRefs?: string[]; + RateLimit?: { + Requests: number; + WindowSeconds: number; + }; + Description?: string; + }[]; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "MessageQueue"; + properties: { + QueueName: string; + Description: string; + /** @enum {string} */ + Type: "Queue" | "Topic"; + /** @enum {string} */ + Provider: "RabbitMQ" | "Kafka" | "AWS_SQS" | "Generic"; + /** @description Message body → DTO node Name */ + MessageFormat: string; + /** @enum {string} */ + DeliveryGuarantee?: "at-least-once" | "exactly-once" | "at-most-once"; + MaxRetries?: number; + DeadLetterQueue?: string; + RetentionSeconds?: number; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "Repository"; + properties: { + RepositoryName: string; + Description: string; + /** @description Manages → Model/Table node Name */ + EntityReference: string; + /** @description Inherited repository base class */ + BaseClass?: string; + /** @default false */ + IsCached?: boolean; + /** @default [] */ + CustomQueries?: { + QueryName: string; + /** + * @default custom + * @enum {string} + */ + QueryType?: "find" | "findOne" | "aggregate" | "raw" | "custom"; + /** @default [] */ + Parameters?: { + Name: string; + Type: string; + }[]; + ReturnType: string; + Description?: string; + }[]; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "Cache"; + properties: { + CacheName: string; + Description: string; + KeyPattern: string; + TTL_Seconds: number; + /** @enum {string} */ + Engine: "Redis" | "Memcached" | "Memory"; + /** @enum {string} */ + EvictionPolicy?: "LRU" | "LFU" | "FIFO" | "TTL"; + MaxSizeMB?: number; + /** @enum {string} */ + Serialization?: "json" | "binary" | "string"; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "ExternalService"; + properties: { + ServiceName: string; + Description: string; + /** Format: uri */ + BaseURL: string; + /** @enum {string} */ + AuthType: "None" | "Basic" | "Bearer" | "API_Key"; + TimeoutSeconds: number; + /** @default [] */ + Endpoints?: { + Name: string; + /** @enum {string} */ + Method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + Path: string; + }[]; + RetryPolicy?: { + MaxRetries: number; + DelaySeconds?: number; + }; + RateLimit?: { + Requests: number; + WindowSeconds: number; + }; + CircuitBreaker?: { + FailureThreshold: number; + ResetSeconds: number; + }; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "FrontendApp"; + properties: { + AppName: string; + Description: string; + /** @enum {string} */ + Framework: "React" | "Vue" | "Angular" | "Svelte" | "Vanilla"; + /** @enum {string} */ + DeploymentType: "SPA" | "SSR" | "SSG"; + /** @enum {string} */ + StateManagement?: "Redux" | "Zustand" | "Context" | "Pinia" | "Vuex" | "NgRx" | "None"; + /** @enum {string} */ + StylingApproach?: "CSS" | "SCSS" | "Tailwind" | "StyledComponents" | "CSSModules"; + /** @default [] */ + Routes?: { + Path: string; + /** @description → UIComponent node Name */ + ComponentRef?: string; + }[]; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "UIComponent"; + properties: { + ComponentName: string; + Description: string; + /** @default [] */ + Props?: { + Name: string; + Type: string; + /** @default false */ + Required?: boolean; + }[]; + /** @default [] */ + State?: { + Name: string; + Type: string; + }[]; + /** @default [] */ + Events?: { + Name: string; + PayloadType?: string; + }[]; + /** + * @description → UIComponent node Names + * @default [] + */ + ChildComponentRefs?: string[]; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "Middleware"; + properties: { + MiddlewareName: string; + Description: string; + /** @enum {string} */ + AppliesTo: "Global" | "SpecificRoutes"; + ExecutionOrder: number; + /** @enum {string} */ + MiddlewareType?: "Auth" | "Logging" | "RateLimit" | "Cors" | "Compression" | "ErrorHandler" | "Custom"; + /** + * @description Middleware configuration key-value pairs + * @default [] + */ + Config?: { + Key: string; + Value: string; + }[]; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "EnvironmentVariable"; + properties: { + Key: string; + Description: string; + /** @enum {string} */ + DataType: "String" | "Number" | "Boolean"; + IsSecret: boolean; + Environment: ("Dev" | "Staging" | "Prod")[]; + DefaultValue?: string; + /** @default true */ + IsRequired?: boolean; + /** @description Regex validation pattern */ + ValidationPattern?: string; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "Exception"; + properties: { + ExceptionName: string; + Description: string; + HttpStatusCode: number; + /** @enum {string} */ + LogSeverity: "Info" | "Warning" | "Error" | "Critical"; + /** @description Application error code, e.g. ERR_USER_NOT_FOUND */ + ErrorCode?: string; + /** @description Inherited → Exception node Name */ + ParentExceptionRef?: string; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "Module"; + properties: { + ModuleName: string; + Description: string; + StrictBoundaries: boolean; + /** + * @description Exposed → Service node Names (public API) + * @default [] + */ + ExposedServices?: string[]; + /** + * @description Depends on → Module node Names + * @default [] + */ + Dependencies?: string[]; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "APIGateway"; + properties: { + GatewayName: string; + Description: string; + /** @enum {string} */ + Provider: "Kong" | "Nginx" | "AWS_API_Gateway" | "Azure_API_Management" | "Generic"; + /** @enum {string} */ + AuthMode?: "None" | "JWT" | "OAuth2" | "ApiKey"; + CorsEnabled?: boolean; + /** @default [] */ + Routes?: { + Path: string; + /** @description → Controller or Service node Name */ + TargetRef: string; + Methods: ("GET" | "POST" | "PUT" | "DELETE" | "PATCH")[]; + /** @default false */ + AuthRequired?: boolean; + RateLimit?: { + Requests: number; + WindowSeconds: number; + }; + }[]; + }; + } | { + /** Format: uuid */ + projectId: string; + position: { + x: number; + y: number; + }; + /** Format: uuid */ + homeTabId?: string; + /** @enum {string} */ + type: "Orchestrator"; + properties: { + OrchestratorName: string; + Description: string; + /** @enum {string} */ + Pattern: "Saga" | "CompensatingTransaction" | "StateMachine" | "ProcessManager"; + /** @default [] */ + Steps?: { + StepName: string; + /** @description Step executor → Service node Name */ + ServiceRef: string; + Action: string; + /** @description Saga compensation action */ + CompensationAction?: string; + /** + * @default abort + * @enum {string} + */ + OnFailure?: "retry" | "compensate" | "abort"; + }[]; + }; + }; + }; + }; + responses: { + /** @description Node created — returns the full node object. */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_SCHEMA_INVALID` (schema) or `ERR_PROJECT_MISMATCH` (URL ≠ body projectId). */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_PROJECT_NOT_FOUND` — create the project first. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_ID_CONFLICT` (id exists) or `ERR_NAME_DUPLICATE` (*Name collision). */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + NodesController_getById: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + /** @description Node UUID */ + nodeId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Full node object. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_NODE_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + NodesController_delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + /** @description Node UUID */ + nodeId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted (no body). */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_NODE_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + NodesController_update: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + /** @description Node UUID */ + nodeId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + position?: { + x: number; + y: number; + }; + properties?: { + [key: string]: unknown; + }; + type?: string; + }; + }; + }; + responses: { + /** @description Updated node (`updatedAt` is refreshed). */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_SCHEMA_INVALID` or `ERR_KIND_IMMUTABLE`. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_NODE_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + NodeTypesController_listAll: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description `data: { types: [...], total: 21 }`. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + NodeTypesController_getById: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Node kind (e.g. `Table`, `Service`, `Controller`) */ + typeId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Metadata + `schema` (JSON Schema). */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_NODE_TYPE_NOT_FOUND` — valid types are listed in the message. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + NodeTypesController_getRules: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Node kind */ + typeId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description allow/deny rule lists. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_NODE_TYPE_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + RulesController_catalog: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description whitelist + blacklist + conditional + layers + counts. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + EdgesController_list: { + parameters: { + query?: { + /** @description Target node UUID filter. */ + targetNodeId?: unknown; + /** @description Source node UUID filter. */ + sourceNodeId?: unknown; + /** @description Edge kind filter (e.g. `CALLS`). */ + kind?: unknown; + }; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description `data: { edges: [...], total }`. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + EdgesController_create: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uuid */ + projectId: string; + /** Format: uuid */ + sourceNodeId: string; + /** Format: uuid */ + targetNodeId: string; + /** @enum {string} */ + kind: "CALLS" | "REQUESTS" | "PUBLISHES" | "SUBSCRIBES" | "USES" | "HAS" | "EXTENDS" | "IMPLEMENTS" | "RETURNS" | "QUERIES" | "WRITES" | "CACHES_IN" | "DEPENDS_ON" | "READS_CONFIG" | "THROWS" | "ROUTES_TO"; + properties: { + Label?: string; + IsAsync: boolean; + /** @enum {string} */ + Protocol?: "HTTP" | "gRPC" | "TCP" | "WebSocket" | "AMQP" | "MQTT"; + RetryCount?: number; + }; + }; + }; + }; + responses: { + /** @description Edge created — full edge object. */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_SCHEMA_INVALID`, `ERR_PROJECT_MISMATCH`, or `ERR_EDGE_SELF_LOOP`. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_PROJECT_NOT_FOUND`, `ERR_EDGE_SOURCE_NOT_FOUND`, or `ERR_EDGE_TARGET_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_EDGE_DUPLICATE`, `ERR_001..ERR_007` (blacklist), `ERR_COND_001/002` (conditional), or `ERR_NOT_WHITELISTED` (default deny). `error.suggestion` offers a fix recommendation. */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + EdgesController_getById: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + /** @description Edge UUID */ + edgeId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Full edge object. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_EDGE_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + EdgesController_delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + /** @description Edge UUID */ + edgeId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted (no body). */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_EDGE_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + EdgesController_update: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + /** @description Edge UUID */ + edgeId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + properties?: { + Label?: string; + IsAsync: boolean; + /** @enum {string} */ + Protocol?: "HTTP" | "gRPC" | "TCP" | "WebSocket" | "AMQP" | "MQTT"; + RetryCount?: number; + }; + kind?: string; + sourceNodeId?: string; + targetNodeId?: string; + }; + }; + }; + responses: { + /** @description Updated edge. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_SCHEMA_INVALID`, `ERR_EDGE_IMMUTABLE`, or `ERR_PATCH_EMPTY`. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_EDGE_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + EdgesController_validate: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uuid */ + sourceNodeId: string; + /** Format: uuid */ + targetNodeId: string; + /** @enum {string} */ + kind: "CALLS" | "REQUESTS" | "PUBLISHES" | "SUBSCRIBES" | "USES" | "HAS" | "EXTENDS" | "IMPLEMENTS" | "RETURNS" | "QUERIES" | "WRITES" | "CACHES_IN" | "DEPENDS_ON" | "READS_CONFIG" | "THROWS" | "ROUTES_TO"; + }; + }; + }; + responses: { + /** @description Always 200. `data.isValid` true/false — rule result in `data.engineResult`. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + EdgeTypesController_listAll: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description `data: { types: [...], total: 16 }`. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + EdgeTypesController_getById: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Edge kind (e.g. `CALLS`, `WRITES`, `PUBLISHES`) */ + edgeKind: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Edge type metadata. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_EDGE_TYPE_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + EdgeTypesController_getRules: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Edge kind */ + edgeKind: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description allow + deny rule lists. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_EDGE_TYPE_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + GraphController_apply: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uuid */ + tabId?: string; + mutations: { + nodes: { + tempId: string; + type: string; + properties: { + [key: string]: unknown; + }; + }[]; + edges: { + sourceTempId: string; + targetTempId: string; + /** @enum {string} */ + edgeType: "CALLS" | "REQUESTS" | "PUBLISHES" | "SUBSCRIBES" | "USES" | "HAS" | "EXTENDS" | "IMPLEMENTS" | "RETURNS" | "QUERIES" | "WRITES" | "CACHES_IN" | "DEPENDS_ON" | "READS_CONFIG" | "THROWS" | "ROUTES_TO"; + label?: string; + }[]; + }; + }; + }; + }; + responses: { + /** @description `data.success=true` → idMap + nodeCount + edgeCount. `data.success=false` → transactionStatus ROLLED_BACK + violations[]. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_PROJECT_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AiController_chat: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + message: string; + /** @default [] */ + history?: { + /** @enum {string} */ + role: "user" | "assistant"; + content: string; + }[]; + /** Format: uuid */ + tabId?: string; + }; + }; + }; + responses: { + /** @description AI response + (if any) applied architecture. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_PROJECT_NOT_FOUND`. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `ERR_AI_NOT_CONFIGURED` — LLM API key is missing. */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + PatternsController_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + PatternsController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + description: string; + /** @default [] */ + tags?: string[]; + graph: { + nodes: { + tempId: string; + type: string; + properties: { + [key: string]: unknown; + }; + }[]; + /** @default [] */ + edges?: { + sourceTempId: string; + targetTempId: string; + /** @enum {string} */ + edgeType: "CALLS" | "REQUESTS" | "PUBLISHES" | "SUBSCRIBES" | "USES" | "HAS" | "EXTENDS" | "IMPLEMENTS" | "RETURNS" | "QUERIES" | "WRITES" | "CACHES_IN" | "DEPENDS_ON" | "READS_CONFIG" | "THROWS" | "ROUTES_TO"; + label?: string; + }[]; + }; + }; + }; + }; + responses: { + /** @description Created pattern summary. */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description ERR_EMBEDDINGS_NOT_CONFIGURED — no embedding provider configured. */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + PatternsController_getById: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Pattern UUID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description ERR_PATTERN_NOT_FOUND */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + PatternsController_delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Pattern UUID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description ERR_PATTERN_NOT_FOUND */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + PatternsController_search: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + query: string; + k?: number; + minScore?: number; + }; + }; + }; + responses: { + /** @description [{ pattern, score }] sorted by similarity. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + PatternsController_promote: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project UUID */ + projectId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + description: string; + /** @default [] */ + tags?: string[]; + nodeIds?: string[]; + }; + }; + }; + responses: { + /** @description ERR_PROJECT_NOT_FOUND / ERR_PATTERN_NODE_NOT_FOUND */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; +} diff --git a/apps/web/src/api/tabs.ts b/apps/web/src/api/tabs.ts new file mode 100644 index 0000000..8b6c96f --- /dev/null +++ b/apps/web/src/api/tabs.ts @@ -0,0 +1,125 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, unwrap } from "./client"; + +export interface Tab { + id: string; + projectId: string; + name: string; + isDefault: boolean; + order: number; + moduleNodeId?: string; + createdAt: string; + updatedAt: string; +} + +export interface TabGraphMember { + id: string; + type: string; + properties: Record; + position: { x: number; y: number }; + version: number; // optimistic concurrency — backend sends via memberFrom + isReference: boolean; + origin?: string; + // Implementation counters (reported by the Solarch CLI / VS Code extension). + implTotal?: number; + implFilled?: number; + implAi?: number; +} +export interface TabGraphEdge { + id: string; + kind: string; + sourceNodeId: string; + targetNodeId: string; +} +export interface TabGraphData { + tab: Tab; + nodes: TabGraphMember[]; + edges: TabGraphEdge[]; +} + +export function useTabGraph(projectId: string, tabId: string | null) { + return useQuery({ + queryKey: ["tab-graph", projectId, tabId], + queryFn: async () => + unwrap( + await api.GET("/api/v1/projects/{projectId}/tabs/{tabId}/graph", { + params: { path: { projectId, tabId: tabId! } }, + }), + ), + enabled: !!projectId && !!tabId, + }); +} + +/** Save position after drag (owned → node.position, referenced → REFERENCES). + * Cache invalidate broader: so canvas refetch triggers on programmatic mutations + * like undo/redo. During drag the scene already does optimistic update — invalidate is idempotent. */ +export function useSaveLayout(projectId: string, tabId: string | null) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (items: { nodeId: string; x: number; y: number }[]) => { + if (!tabId) return; + await api.PATCH("/api/v1/projects/{projectId}/tabs/{tabId}/layout", { + params: { path: { projectId, tabId } }, + body: { items } as never, + }); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["tab-graph"] }), + }); +} + +export function useTabs(projectId: string) { + return useQuery({ + queryKey: ["tabs", projectId], + queryFn: async () => + unwrap( + await api.GET("/api/v1/projects/{projectId}/tabs", { params: { path: { projectId } } }), + ), + enabled: !!projectId, + }); +} + +/** Create a new tab. moduleNodeId is optional (drill-down in the future). */ +export function useCreateTab(projectId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (name: string) => + unwrap( + await api.POST("/api/v1/projects/{projectId}/tabs", { + params: { path: { projectId } }, + body: { name } as never, + }), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: ["tabs", projectId] }), + }); +} + +/** Update tab name or order. */ +export function useUpdateTab(projectId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ tabId, name }: { tabId: string; name?: string }) => + unwrap( + await api.PATCH("/api/v1/projects/{projectId}/tabs/{tabId}", { + params: { path: { projectId, tabId } }, + body: { name } as never, + }), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: ["tabs", projectId] }), + }); +} + +/** Delete tab. The default tab cannot be deleted (backend returns ERR_TAB_DEFAULT_DELETE). + * Owned nodes are moved to Main Architecture, references are removed. */ +export function useDeleteTab(projectId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (tabId: string) => + api.DELETE("/api/v1/projects/{projectId}/tabs/{tabId}", { + params: { path: { projectId, tabId } }, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["tabs", projectId] }); + qc.invalidateQueries({ queryKey: ["tab-graph", projectId] }); + }, + }); +} diff --git a/apps/web/src/api/value-sets.ts b/apps/web/src/api/value-sets.ts new file mode 100644 index 0000000..c8c2478 --- /dev/null +++ b/apps/web/src/api/value-sets.ts @@ -0,0 +1,60 @@ +/** Value-sets API — Solarch's shared enum / lookup catalog. + * Fetched from fieldHint.valueSet references in Inspector forms. */ + +import { useQuery } from "@tanstack/react-query"; +import { api, unwrap } from "./client"; + +// openapi-fetch type layer doesn't know about value-sets endpoints yet +// (will be resolved once schema.d.ts is regenerated). Using any cast for now. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const apiAny = api as any; + +export interface ValueOption { + value: string; + label?: string; + description?: string; + group?: string; +} + +export interface ValueSet { + id: string; + label: string; + description: string; + values: ValueOption[]; +} + +export interface ValueSetSummary { + id: string; + label: string; + description: string; + count: number; +} + +/** List of all value-sets (summary). Static — staleTime Infinity. */ +export function useValueSets() { + return useQuery({ + queryKey: ["value-sets"], + staleTime: Infinity, + queryFn: async () => { + const body = unwrap<{ sets: ValueSetSummary[]; total: number }>( + await apiAny.GET("/api/v1/value-sets"), + ); + return body.sets; + }, + }); +} + +/** Single value-set with all its values. */ +export function useValueSet(id: string | null) { + return useQuery({ + queryKey: ["value-set", id], + enabled: !!id, + staleTime: Infinity, + queryFn: async () => + unwrap( + await apiAny.GET("/api/v1/value-sets/{id}", { + params: { path: { id: id! } }, + }), + ), + }); +} diff --git a/apps/web/src/app/AppShell.css b/apps/web/src/app/AppShell.css new file mode 100644 index 0000000..6c0922e --- /dev/null +++ b/apps/web/src/app/AppShell.css @@ -0,0 +1,22 @@ +.app-shell { + display: flex; + flex-direction: column; + height: 100vh; /* legacy browser fallback */ + height: 100dvh; /* mobile/tablet: account for the browser address+tool bar → the bottom + BottomBar isn't pushed outside the visible area (100vh counts it and clips the bar) */ + width: 100vw; + overflow: hidden; + /* iPad/touch: safe area so the bottom home-indicator bar doesn't cover the BottomBar */ + padding-bottom: env(safe-area-inset-bottom, 0px); + background: var(--paper); +} +.app-main { + flex: 1; + min-height: 0; + overflow: hidden; + position: relative; + /* Own stacking context: the body surfaces (Code/API/Docs panels at MODAL z) stay contained BELOW + the TopBar chrome, so the ViewSwitch sub-mode dropdowns render above them (not covered). */ + isolation: isolate; + background: var(--paper); +} diff --git a/apps/web/src/app/AppShell.tsx b/apps/web/src/app/AppShell.tsx new file mode 100644 index 0000000..3828fcb --- /dev/null +++ b/apps/web/src/app/AppShell.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from "react"; +import { Outlet } from "react-router-dom"; +import { TopBar } from "../components/TopBar"; +import { BottomBar } from "../components/BottomBar"; +import { EditorModal } from "../components/EditorModal"; +import { CommandPalette } from "../components/CommandPalette"; +import { DocsModal, type DocsSection } from "../components/DocsModal"; +import { useSelection } from "../state/selection"; +import { useCanvasCommands } from "../canvas/canvas-commands"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { ConfirmProvider } from "../components/ui/confirm-dialog"; +import { Toaster } from "sonner"; +import { Z_LAYERS } from "../lib/z-layers"; +import "./AppShell.css"; + +export function AppShell() { + const selectedNodeId = useSelection((s) => s.selectedNodeId); + const selectNode = useSelection((s) => s.selectNode); + const [paletteOpen, setPaletteOpen] = useState(false); + const [docsOpen, setDocsOpen] = useState(false); + const [docsSection, setDocsSection] = useState("nodes"); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const t = e.target as HTMLElement | null; + const inForm = t?.tagName === "INPUT" || t?.tagName === "TEXTAREA" || t?.isContentEditable; + const mod = e.metaKey || e.ctrlKey; + + if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k") { + e.preventDefault(); + setPaletteOpen((v) => !v); + return; + } + + if (e.key === "Escape" && !inForm) { + const s = useSelection.getState(); + if (s.nameEditorOpen) { s.closeNameEditor(); return; } + if (s.editorNodeId) { s.closeEditor(); return; } + if (selectedNodeId) { selectNode(null); return; } + } + + if (inForm) return; + + if (mod && !e.shiftKey && !e.altKey && e.key === "e" && selectedNodeId) { + e.preventDefault(); + const s = useSelection.getState(); + if (s.editorNodeId) s.closeEditor(); + else s.openEditor(selectedNodeId); + return; + } + + if (mod && e.shiftKey && (e.key === "C" || e.key === "c") && selectedNodeId) { + e.preventDefault(); + useCanvasCommands.getState().copy?.(); + return; + } + + if (e.key === "F2" && selectedNodeId) { + e.preventDefault(); + useSelection.getState().openNameEditor(); + return; + } + + if ((e.key === "Delete" || e.key === "Backspace") && selectedNodeId) { + e.preventDefault(); + useCanvasCommands.getState().deleteSelected?.(); + return; + } + }; + + const onCmdkEvent = () => setPaletteOpen((v) => !v); + const onDocsEvent = (e: Event) => { + const detail = (e as CustomEvent<{ section?: DocsSection }>).detail; + setDocsSection(detail?.section ?? "nodes"); + setDocsOpen(true); + }; + + window.addEventListener("keydown", onKey); + window.addEventListener("solarch:cmdk-open", onCmdkEvent); + window.addEventListener("solarch:docs-open", onDocsEvent); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("solarch:cmdk-open", onCmdkEvent); + window.removeEventListener("solarch:docs-open", onDocsEvent); + }; + }, [selectedNodeId, selectNode]); + + return ( + + +
    +
    + +
    +
    + +
    +
    + +
    + + { + setDocsSection(section); + setDocsOpen(true); + }} + /> + +
    + +
    +
    + ); +} + +export function openCommandPalette(): void { + window.dispatchEvent(new CustomEvent("solarch:cmdk-open")); +} + +export function openDocs(section: DocsSection = "nodes"): void { + window.dispatchEvent(new CustomEvent("solarch:docs-open", { detail: { section } })); +} diff --git a/apps/web/src/app/GlobalErrorBoundary.tsx b/apps/web/src/app/GlobalErrorBoundary.tsx new file mode 100644 index 0000000..db9a09c --- /dev/null +++ b/apps/web/src/app/GlobalErrorBoundary.tsx @@ -0,0 +1,40 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +/** App-root render error boundary. The Router's errorElement ONLY catches loader/action + * errors; throws during component render crash the entire app to a white screen. This + * boundary (wraps RouterProvider) catches those and shows a simple recovery screen. + * Error catching in React 19 still requires a class component. Since this is OUTSIDE + * the Router context, Link/useNavigate are unavailable — recovery uses window.location. */ +export class GlobalErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { + state: { error: Error | null } = { error: null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + // Single extension-point: in the future Sentry.captureException(error, { extra: info }). + console.error("[solarch] uncaught render error:", error, info.componentStack); + } + + reset = () => this.setState({ error: null }); + + render() { + if (!this.state.error) return this.props.children; + const message = this.state.error.message || "Unknown error"; + return ( +
    +
    // something went wrong
    +
    {message}
    +
    + + +
    +
    + ); + } +} diff --git a/apps/web/src/app/ThemeController.tsx b/apps/web/src/app/ThemeController.tsx new file mode 100644 index 0000000..bf02422 --- /dev/null +++ b/apps/web/src/app/ThemeController.tsx @@ -0,0 +1,15 @@ +/** ThemeController — sync OS preference when mode is "system". */ + +import { useEffect } from "react"; +import { useTheme } from "../state/theme"; + +export function ThemeController() { + const syncSystem = useTheme((s) => s.syncSystem); + useEffect(() => { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const onChange = () => syncSystem(); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, [syncSystem]); + return null; +} diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx new file mode 100644 index 0000000..8f4824d --- /dev/null +++ b/apps/web/src/app/providers.tsx @@ -0,0 +1,51 @@ +import { + QueryClient, + QueryClientProvider, + MutationCache, +} from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { toast } from "sonner"; +import { ApiError } from "../api/client"; +import { ThemeController } from "./ThemeController"; + +const codeOf = (err: unknown): string | undefined => + err instanceof ApiError ? err.code : (err as { code?: string } | null)?.code; + +function handleMutationError(err: unknown) { + const code = codeOf(err); + if (code === "ERR_VERSION_CONFLICT" || code === "ERR_EDGE_DUPLICATE") return; + const message = err instanceof ApiError ? err.message : "An error occurred"; + const suggestion = err instanceof ApiError ? err.suggestion : undefined; + const isRule = code === "ERR_RULES_DENIED" || code === "ERR_NOT_WHITELISTED" || /^ERR_(00[1-7]|COND_00[12])$/.test(code ?? ""); + if (isRule) toast.error(message, { description: suggestion }); + else toast.error("An error occurred", { description: message }); +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 10_000, + refetchOnWindowFocus: false, + retry: (count, error) => { + const c = codeOf(error); + if ( + c === "ERR_NODE_NOT_FOUND" || + c === "ERR_PROJECT_NOT_FOUND" || + c === "ERR_PROJECT_FORBIDDEN" + ) + return false; + return count < 2; + }, + }, + }, + mutationCache: new MutationCache({ onError: handleMutationError }), +}); + +export function AppProviders({ children }: { children: ReactNode }) { + return ( + + + {children} + + ); +} diff --git a/apps/web/src/app/route-guards.tsx b/apps/web/src/app/route-guards.tsx new file mode 100644 index 0000000..7d44dae --- /dev/null +++ b/apps/web/src/app/route-guards.tsx @@ -0,0 +1,20 @@ +import { useRouteError, Link } from "react-router-dom"; +import type { ReactNode } from "react"; + +export function RequireAuth({ children }: { children: ReactNode }) { + return <>{children}; +} + +export function RouteError() { + const error = useRouteError(); + const message = error instanceof Error ? error.message : "Unknown error"; + return ( +
    +
    // something went wrong
    +
    {message}
    + location.reload()}> + reload + +
    + ); +} diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx new file mode 100644 index 0000000..e8c54f0 --- /dev/null +++ b/apps/web/src/app/router.tsx @@ -0,0 +1,29 @@ +import { createBrowserRouter, redirect } from "react-router-dom"; +import { AppShell } from "./AppShell"; +import { Welcome } from "../features/welcome/Welcome"; +import { ProjectPage } from "../features/canvas/ProjectPage"; +import { RequireAuth, RouteError } from "./route-guards"; +import { SettingsPage } from "../features/settings/SettingsPage"; + +export const router = createBrowserRouter([ + { + errorElement: , + children: [ + { path: "/", loader: () => redirect("/start") }, + { + element: ( + + + + ), + errorElement: , + children: [ + { path: "/start", element: }, + { path: "/settings", element: }, + { path: "/p/:projectId", element: }, + { path: "/p/:projectId/:tabId", element: }, + ], + }, + ], + }, +]); diff --git a/apps/web/src/assets/hero.png b/apps/web/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/apps/web/src/assets/hero.png differ diff --git a/apps/web/src/assets/react.svg b/apps/web/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/assets/vite.svg b/apps/web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/apps/web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/apps/web/src/canvas/CanvasA11yMirror.tsx b/apps/web/src/canvas/CanvasA11yMirror.tsx new file mode 100644 index 0000000..842d5b2 --- /dev/null +++ b/apps/web/src/canvas/CanvasA11yMirror.tsx @@ -0,0 +1,89 @@ +import { useRef } from "react"; +import type { TabGraphData } from "../api/tabs"; +import { nameOf, familyOf } from "./families"; + +/** INVISIBLE DOM mirror of the canvas for screen readers + keyboard. + * + * Canvas pixels never reach the accessibility tree (WCAG) → the drawn graph is + * fully invisible to a screen reader. This list makes every node and its + * connections readable. Roving tabindex navigates with arrow keys; focusing an + * item makes the canvas select that node + pan the camera to it (visual ↔ a11y + * sync); Enter/Space opens the editor. + * + * Visually hidden (sr-only) but focusable + announced — the canvas visual stays + * as-is for the sighted user. */ +export function CanvasA11yMirror({ + graph, + selectedId, + onActivate, + onOpen, +}: { + graph: TabGraphData | undefined; + selectedId: string | null; + onActivate: (id: string) => void; + onOpen: (id: string) => void; +}) { + const listRef = useRef(null); + const nodes = graph?.nodes ?? []; + const edges = graph?.edges ?? []; + + if (!nodes.length) return null; + + // Connection blurbs: outgoing/incoming neighbors per node (name + edge kind). + const nameById = new Map(nodes.map((n) => [n.id, nameOf(n.properties)])); + const out = new Map(); + const inc = new Map(); + for (const e of edges) { + const tn = nameById.get(e.targetNodeId); + const sn = nameById.get(e.sourceNodeId); + if (tn) { const a = out.get(e.sourceNodeId) ?? []; a.push(`${e.kind} → ${tn}`); out.set(e.sourceNodeId, a); } + if (sn) { const a = inc.get(e.targetNodeId) ?? []; a.push(`${sn} (${e.kind})`); inc.set(e.targetNodeId, a); } + } + + const focusAt = (idx: number) => { + listRef.current?.querySelectorAll("button[data-node]")[idx]?.focus(); + }; + const onKey = (e: React.KeyboardEvent, i: number) => { + const last = nodes.length - 1; + if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); focusAt(i === last ? 0 : i + 1); } + else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); focusAt(i === 0 ? last : i - 1); } + else if (e.key === "Home") { e.preventDefault(); focusAt(0); } + else if (e.key === "End") { e.preventDefault(); focusAt(last); } + }; + + // Roving tabindex: selected node (or first) tabindex 0, rest -1 — single Tab stop. + const activeIdx = Math.max(0, nodes.findIndex((n) => n.id === selectedId)); + + return ( +
      + {nodes.map((n, i) => { + const nm = nameOf(n.properties); + const fam = familyOf(n.type); + const outs = out.get(n.id); + const incs = inc.get(n.id); + const conn = + [outs?.length ? `Connects to: ${outs.join(", ")}` : "", incs?.length ? `Incoming: ${incs.join(", ")}` : ""] + .filter(Boolean).join(". ") || "No connections"; + return ( +
    • + +
    • + ); + })} +
    + ); +} diff --git a/apps/web/src/canvas/CanvasView.css b/apps/web/src/canvas/CanvasView.css new file mode 100644 index 0000000..2ecf4b4 --- /dev/null +++ b/apps/web/src/canvas/CanvasView.css @@ -0,0 +1,58 @@ +.cv-wrap { position: absolute; inset: 0; } +.cv-canvas { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; } + +.cv-hud { + position: absolute; + left: 12px; + bottom: 12px; + font-size: 12px; + color: var(--ink-soft); + background: var(--glass-bg); + border: 1px solid var(--hairline); + border-radius: 6px; + padding: 4px 9px; + backdrop-filter: blur(6px); + pointer-events: none; +} + +.cv-controls { + position: absolute; + right: 12px; + bottom: 12px; + display: flex; + flex-direction: row; + align-items: center; + gap: 1px; + padding: 4px; + background: var(--glass-bg-strong); + border: 1px solid var(--hairline); + border-radius: 10px; + box-shadow: var(--shadow-card); + backdrop-filter: blur(16px) saturate(1.8); +} + +.cv-btn { + width: 32px; + height: 32px; + border: 0; + background: transparent; + color: var(--ink-soft); + border-radius: 7px; + font-size: 14px; + line-height: 1; + cursor: pointer; + display: grid; + place-items: center; + transition: background 0.1s, color 0.1s; +} +.cv-btn:hover { background: var(--hairline); color: var(--ink); } +.cv-btn:disabled { opacity: 0.35; cursor: not-allowed; } +.cv-btn.is-active { background: var(--accent); color: #000; } +.cv-btn.is-active:hover { background: var(--accent); } + +.cv-sep { + width: 1px; + align-self: stretch; + background: var(--hairline); + margin: 3px 2px; +} diff --git a/apps/web/src/canvas/CanvasView.tsx b/apps/web/src/canvas/CanvasView.tsx new file mode 100644 index 0000000..86dd802 --- /dev/null +++ b/apps/web/src/canvas/CanvasView.tsx @@ -0,0 +1,1270 @@ +import { useEffect, useRef } from "react"; +import type { TabGraphData } from "../api/tabs"; +import { drawScene, drawPendingEdge, PORT_R, BEND_HANDLE_R, STUB_LEN_WORLD, HOP_MIN_ZOOM, elbowGeom, portOf, nodeDisplayH, type EdgeHop } from "./renderer"; +import { NODE_H, ANIM_NODE_POP_MS, ANIM_EDGE_FADE_MS, ANIM_EDGE_DELAY_MS, ANIM_LERP_FACTOR, FOCUS_FADE_FACTOR, type Scene, type SceneNode, type SceneEdge, type Viewport, type FocusSet } from "./types"; +import { nameOf, familyOf } from "./families"; +import { nodeDefaultW } from "./node-templates"; +import { arrangeNodes } from "./arrange"; +import { autoBendRatio } from "./edge-router"; +import { computeBundles, computeCorridors, type Bundles } from "./edge-bundling"; +import { computeEdgeHops } from "./edge-hops"; +import { isAiActive } from "../api/ai"; +import { useUiPrefs } from "../state/ui-prefs"; +import { useCanvasState } from "../state/canvas-state"; +import { useSelection } from "../state/selection"; +import { useHistory } from "../state/history"; +import { usePendingProposal } from "../state/pending-proposal"; +import { useCanvasCommands } from "./canvas-commands"; +import { hapticTap, hapticConfirm } from "../lib/haptics"; +import { useTouchMode } from "../hooks/useTouchMode"; +import { CanvasA11yMirror } from "./CanvasA11yMirror"; +import "./CanvasView.css"; + +/** Diff-aware buildScene — for AI streaming + manual mutation. + * - New node/edge: enterStart=now → triggers pop animation + * - Existing node: position preserved (drag/optimistic update not broken), + * properties/name are refreshed. + * If prefersReducedMotion, pop animations are disabled (enterStart=undefined). */ +interface BuildResult { + scene: Scene; + newNodeIds: string[]; + newEdgeIds: string[]; + /** Existing nodes whose version increased (content edited) → "edited" pulse. */ + editedNodeIds: string[]; +} + +function buildScene(graph: TabGraphData, prev: Scene | null, now: number, reducedMotion: boolean): BuildResult { + const prevIdx = prev?.index; + const prevEdgeIdx = prev ? new Map(prev.edges.map((e) => [e.id, e])) : null; + const newNodeIds: string[] = []; + const newEdgeIds: string[] = []; + const editedNodeIds: string[] = []; + + const nodes: SceneNode[] = graph.nodes.map((m) => { + const old = prevIdx?.get(m.id); + if (old) { + // Existing node — current pos & enterStart preserved (drag/anim continues), + // only content fields refreshed (AI can update). + // Version increased = content edited (rename/refactor) → "edited" pulse. + if (m.version !== undefined && old.version !== undefined && m.version > old.version) { + editedNodeIds.push(m.id); + } + return { + ...old, + type: m.type, + name: nameOf(m.properties), + family: familyOf(m.type), + w: nodeDefaultW(m.type), + isReference: m.isReference, + version: m.version, // optimistic concurrency (canvas rename) + implTotal: m.implTotal, implFilled: m.implFilled, implAi: m.implAi, + properties: m.properties, + }; + } + // New node — backend provides position; arrange provides targetX/Y + newNodeIds.push(m.id); + return { + id: m.id, type: m.type, name: nameOf(m.properties), + family: familyOf(m.type), + x: m.position.x, y: m.position.y, + w: nodeDefaultW(m.type), h: NODE_H, + isReference: m.isReference, + version: m.version, + implTotal: m.implTotal, implFilled: m.implFilled, implAi: m.implAi, + properties: m.properties, + enterStart: reducedMotion ? undefined : now, + }; + }); + const index = new Map(nodes.map((n) => [n.id, n])); + + const edges: SceneEdge[] = graph.edges.map((e) => { + const old = prevEdgeIdx?.get(e.id); + if (old) { + return { ...old, kind: e.kind, source: e.sourceNodeId, target: e.targetNodeId }; + } + newEdgeIds.push(e.id); + return { + id: e.id, kind: e.kind, source: e.sourceNodeId, target: e.targetNodeId, + enterStart: reducedMotion ? undefined : now, + }; + }); + return { scene: { nodes, edges, index }, newNodeIds, newEdgeIds, editedNodeIds }; +} + +/** prefers-reduced-motion media query — accessibility. */ +function getReducedMotion(): boolean { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); + +export function CanvasView({ graph, onNodeMoved, onContextMenu, onEdgeDrop, onArrange, onApplyLayout, onEdgeDelete }: { + graph: TabGraphData; + onNodeMoved?: (nodeId: string, x: number, y: number) => void; + onContextMenu?: (world: { x: number; y: number }, screen: { x: number; y: number }) => void; + onEdgeDrop?: (nodeId: string, side: "in" | "out", world: { x: number; y: number }, screen: { x: number; y: number }, targetNodeId?: string) => void; + onArrange?: (items: { nodeId: string; x: number; y: number }[]) => void; + /** Programmatic layout apply for Undo/Redo buttons — routed to saveLayout.mutate(items). */ + onApplyLayout?: (items: { nodeId: string; x: number; y: number }[]) => void; + onEdgeDelete?: (edgeId: string) => void; +}) { + const canvasRef = useRef(null); + const vp = useRef({ x: 0, y: 0, zoom: 1 }); + const scene = useRef({ nodes: [], edges: [], index: new Map() }); + const size = useRef({ w: 0, h: 0, dpr: 1 }); + const raf = useRef(0); + const fitted = useRef(false); + const selected = useRef(null); + const hovered = useRef(null); + const pending = useRef<{ sourceId: string; side: "in" | "out"; cur: { x: number; y: number } } | null>(null); + const hud = useRef(null); + const hoveredEdge = useRef(null); + const selectedEdge = useRef(null); + const edgePath = useUiPrefs((s) => s.edgePath); + const edgePathRef = useRef(edgePath); + edgePathRef.current = edgePath; + const edgeBends = useCanvasState((s) => s.edgeBends); + const setBend = useCanvasState((s) => s.setBend); + const edgeBendsRef = useRef(edgeBends); + edgeBendsRef.current = edgeBends; + // Touch mode (coarse pointer) — render loop + hit-tests read it via ref. + const isTouchMode = useTouchMode().isTouch; + const coarseRef = useRef(isTouchMode); + coarseRef.current = isTouchMode; + // Tap-to-connect: "armed" source port — tap port→ARM, next tap connects the target + // (WCAG 2.5.7 drag-free alternative). Render loop reads it from ref for highlight. + const armedPortRef = useRef<{ nodeId: string; side: "in" | "out" } | null>(null); + // Pending AI proposal (green highlight) — render loop reads it via ref. + const proposalNodes = usePendingProposal((s) => s.nodeIds); + const proposalEdges = usePendingProposal((s) => s.edgeIds); + const proposalRef = useRef<{ nodes: Set; edges: Set } | null>(null); + // Global selection store for sidebar Inspector + const selectNode = useSelection((s) => s.selectNode); + const selectedFromStore = useSelection((s) => s.selectedNodeId); + const selectedFromStoreRef = useRef(selectedFromStore); + selectedFromStoreRef.current = selectedFromStore; + // History — disabled state subscription for undo/redo buttons + const canUndo = useHistory((s) => s.past.length > 0); + const canRedo = useHistory((s) => s.future.length > 0); + const onApplyLayoutRef = useRef(onApplyLayout); + onApplyLayoutRef.current = onApplyLayout; + const onEdgeDeleteRef = useRef(onEdgeDelete); + onEdgeDeleteRef.current = onEdgeDelete; + const onEdgeDropRef = useRef(onEdgeDrop); + onEdgeDropRef.current = onEdgeDrop; + + // Optimistic apply: update scene instantly (visual immediate) + write to backend (async) + const applyLayoutItems = (items: { nodeId: string; x: number; y: number }[]) => { + for (const item of items) { + const n = scene.current.index.get(item.nodeId); + if (n) { n.x = item.x; n.y = item.y; } + } + schedule(); + onApplyLayoutRef.current?.(items); + }; + + const onUndoClick = () => { useHistory.getState().undo(); }; + const onRedoClick = () => { useHistory.getState().redo(); }; + + // Soft focus for AI chat NodeChip / EdgeChip — viewport target + highlight halo. + // When vpTarget is set, render loop lerps current viewport towards it each frame, + // becomes undefined when settled. Highlight 600ms fade-out. + const vpTarget = useRef(null); + const focusHighlight = useRef<{ nodeIds: Set; edgeId: string | null; start: number; duration: number } | null>(null); + + // autoBendRatio cache — computed once per edge as long as sceneSig hasn't changed. + // If a node moves during drag, sig changes → entire cache is invalidated, + // recomputed. In steady state, saves O(E) across frames. + const routeCache = useRef<{ sig: number; map: Map }>({ sig: -1, map: new Map() }); + + // Bundle cache — same sceneSig pattern; computed once for all edges (port-spread). + const bundleCache = useRef<{ sig: number; bundles: Bundles | null }>({ sig: -1, bundles: null }); + + // Corridor cache — offsets that spread elbow middle segments side by side (same sig pattern). + const corridorCache = useRef<{ sig: number; map: Map | null }>({ sig: -1, map: null }); + + // Hop cache — crossing hops. O(E²·S²) cost → computed only when the scene is settled + // (no animation/lerp); during animation drawn without hops. + const hopsCache = useRef<{ sig: number; mode: string; map: Map | null }>({ sig: -1, mode: "", map: null }); + + // ── Selection spotlight (focus subgraph) ─────────────────────────── + // focusSet = selected node + its 1-hop neighbours + incident edges. + // Recomputed ONCE per (selection, edge-topology) change — guarded by focusSig, + // NOT per frame. dimAmount lerps 0→1 (focus on) / 1→0 (focus off) for a short fade. + const focusSet = useRef(null); + const focusSig = useRef(""); + const dimAmount = useRef(0); + // Instruct-narration focus — the node currently being highlighted by an instruct + // marker (focusNode({ instruct: true })). This is an ALTERNATE spotlight source, + // independent of canvas selection (it never writes selectedNodeId). When set it + // takes priority over the selection as the spotlight origin; cleared when the + // instruct panel closes / a new stream starts. + const instructFocusId = useRef(null); + + /** Recompute focusSet if the spotlight source (instruct-focus OR selection) or the + * edge topology changed. Cheap O(E) numeric hash per frame; the actual set rebuild + * (also O(E)) only runs when the signature differs (≈ once per select / per instruct + * marker / per AI edge add), not every frame. */ + const ensureFocusSet = () => { + // Spotlight source: active instruct-narration node takes priority, else canvas + // selection. Either one lights up the same selected+1-hop+incident subgraph. + const src = instructFocusId.current ?? selectedFromStoreRef.current; + // Topology hash — folds edge id/source/target into a 32-bit int. Cheap, no + // allocation; changes only when the edge set or its endpoints change. + const sceneEdges = scene.current.edges; + let topoHash = sceneEdges.length; + for (const e of sceneEdges) { + for (let i = 0; i < e.id.length; i++) topoHash = (topoHash * 31 + e.id.charCodeAt(i)) | 0; + for (let i = 0; i < e.source.length; i++) topoHash = (topoHash * 31 + e.source.charCodeAt(i)) | 0; + for (let i = 0; i < e.target.length; i++) topoHash = (topoHash * 31 + e.target.charCodeAt(i)) | 0; + } + const sig = (src ?? "∅") + "#" + topoHash; + if (sig === focusSig.current) return; + focusSig.current = sig; + if (!src || !scene.current.index.has(src)) { + focusSet.current = null; + return; + } + const nodes = new Set([src]); + const edges = new Set(); + for (const e of scene.current.edges) { + if (e.source === src) { edges.add(e.id); nodes.add(e.target); } + else if (e.target === src) { edges.add(e.id); nodes.add(e.source); } + } + focusSet.current = { nodes, edges }; + }; + + // Render-time bend calculation: manual override takes priority, otherwise obstacle-aware auto-route (cached). + const getBendForRender = (edgeId: string): number | undefined => { + const explicit = edgeBendsRef.current[edgeId]; + if (explicit !== undefined) return explicit; + if (edgePathRef.current !== "elbow") return undefined; + const cached = routeCache.current.map.get(edgeId); + if (cached !== undefined) return cached; + const sc = scene.current; + const e = sc.edges.find((x) => x.id === edgeId); + if (!e) return undefined; + const a = sc.index.get(e.source); const b = sc.index.get(e.target); + if (!a || !b) return undefined; + const obstacles = sc.nodes.filter((n) => n.id !== a.id && n.id !== b.id); + const r = autoBendRatio(a, b, obstacles); + routeCache.current.map.set(edgeId, r); + return r; + }; + + const render = () => { + raf.current = 0; + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // ── AI streaming animation advance ───────────────────────────── + // Nodes/edges whose enterStart duration has elapsed are marked "settled" (undef). + // If targetX/Y exists, current x/y approaches via exponential lerp. + // If at least one node/edge is still animating, frame loop re-arms itself. + const now = performance.now(); + let stillAnimating = false; + for (const n of scene.current.nodes) { + if (n.enterStart !== undefined) { + if (now - n.enterStart >= ANIM_NODE_POP_MS) n.enterStart = undefined; + else stillAnimating = true; + } + if (n.targetX !== undefined && n.targetY !== undefined) { + const dx = n.targetX - n.x; + const dy = n.targetY - n.y; + if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) { + n.x = n.targetX; n.y = n.targetY; + n.targetX = undefined; n.targetY = undefined; + } else { + n.x += dx * ANIM_LERP_FACTOR; + n.y += dy * ANIM_LERP_FACTOR; + stillAnimating = true; + } + } + } + for (const e of scene.current.edges) { + if (e.enterStart !== undefined) { + if (now - e.enterStart >= ANIM_NODE_POP_MS + ANIM_EDGE_DELAY_MS + ANIM_EDGE_FADE_MS) { + e.enterStart = undefined; + } else { + stillAnimating = true; + } + } + } + + // Viewport target lerp — set by focusNode/focusEdge calls + if (vpTarget.current) { + const t = vpTarget.current; + const dx = t.x - vp.current.x; + const dy = t.y - vp.current.y; + const dz = t.zoom - vp.current.zoom; + if (Math.abs(dx) < 0.4 && Math.abs(dy) < 0.4 && Math.abs(dz) < 0.002) { + vp.current = { ...t }; + vpTarget.current = null; + } else { + vp.current.x += dx * ANIM_LERP_FACTOR; + vp.current.y += dy * ANIM_LERP_FACTOR; + vp.current.zoom += dz * ANIM_LERP_FACTOR; + stillAnimating = true; + } + } + // Highlight halo expire + if (focusHighlight.current) { + if (now - focusHighlight.current.start >= focusHighlight.current.duration) { + focusHighlight.current = null; + } else { + stillAnimating = true; + } + } + + // Selection spotlight — recompute focus set once (guarded), lerp dimAmount. + ensureFocusSet(); + const dimTarget = focusSet.current ? 1 : 0; + const dd = dimTarget - dimAmount.current; + if (Math.abs(dd) > 0.002) { + dimAmount.current += dd * FOCUS_FADE_FACTOR; + stillAnimating = true; + } else { + dimAmount.current = dimTarget; + } + + // Defer fit until size is known; fit once when known. + if (!fitted.current && size.current.w > 0 && scene.current.nodes.length > 0) { + fit(); + fitted.current = true; + } + // Cache invalidation — sceneSig is a topology+pos hash; all geometry caches share it. + // Edge set and manual bends also change geometry → included in sig + // (otherwise edge add/remove or bend drag leaves the corridor/hop cache stale). + const ns = scene.current.nodes; + let sig = ns.length; + for (const n of ns) sig = (sig * 31 + n.x * 73 + n.y) | 0; + for (const e of scene.current.edges) { + for (let ci = 0; ci < e.id.length; ci += 7) sig = (sig * 33 + e.id.charCodeAt(ci)) | 0; + } + const bends = edgeBendsRef.current; + for (const k in bends) sig = (sig * 31 + ((bends[k] * 1000) | 0)) | 0; + if (edgePathRef.current === "elbow" && routeCache.current.sig !== sig) { + routeCache.current.sig = sig; + routeCache.current.map.clear(); + } + if (bundleCache.current.sig !== sig) { + bundleCache.current.sig = sig; + bundleCache.current.bundles = computeBundles(scene.current.edges, scene.current.index); + } + const getBundle = (edgeId: string) => { + const b = bundleCache.current.bundles; + if (!b) return undefined; + const s = b.src.get(edgeId) ?? 0; + const t = b.tgt.get(edgeId) ?? 0; + return s === 0 && t === 0 ? undefined : { src: s, tgt: t }; + }; + // Corridor spread — only meaningful in elbow mode (middle segment shift). + if (edgePathRef.current === "elbow") { + if (corridorCache.current.sig !== sig) { + corridorCache.current.sig = sig; + corridorCache.current.map = computeCorridors( + scene.current.edges, scene.current.index, getBendForRender, STUB_LEN_WORLD, portOf, + ); + } + } else { + corridorCache.current.map = null; + corridorCache.current.sig = -1; + } + // Crossing hops — expensive; only when the scene is settled + sufficient zoom. + // During animation the old sig is kept → hopless draw (visually natural: + // a hop on a moving line is unreadable anyway). + if (!stillAnimating && vp.current.zoom >= HOP_MIN_ZOOM) { + if (hopsCache.current.sig !== sig || hopsCache.current.mode !== edgePathRef.current) { + hopsCache.current.sig = sig; + hopsCache.current.mode = edgePathRef.current; + hopsCache.current.map = computeEdgeHops( + scene.current, edgePathRef.current, getBendForRender, + (id) => getBundle(id) ?? { src: 0, tgt: 0 }, + corridorCache.current.map, + ); + } + } else if (stillAnimating) { + hopsCache.current.map = null; + hopsCache.current.sig = -1; + } + ctx.setTransform(size.current.dpr, 0, 0, size.current.dpr, 0, 0); + const p = pending.current; + drawScene(ctx, size.current.w, size.current.h, scene.current, vp.current, selected.current, hovered.current, edgePathRef.current, getBendForRender, hoveredEdge.current, selectedEdge.current, getBundle, now, focusSet.current, dimAmount.current, proposalRef.current, corridorCache.current.map, hopsCache.current.map, coarseRef.current, armedPortRef.current); + + // Focus highlight halo (orange, fade out) — overlay after drawScene + if (focusHighlight.current) { + const fh = focusHighlight.current; + const elapsed = now - fh.start; + const t = Math.min(1, elapsed / fh.duration); + const alpha = Math.max(0, 1 - t); // linear fade out + ctx.save(); + ctx.strokeStyle = `rgba(255, 138, 61, ${alpha * 0.95})`; + ctx.shadowColor = `rgba(255, 138, 61, ${alpha * 0.55})`; + ctx.shadowBlur = 24; + ctx.lineWidth = 3; + for (const nid of fh.nodeIds) { + const n = scene.current.index.get(nid); + if (!n) continue; + const x = n.x * vp.current.zoom + vp.current.x; + const y = n.y * vp.current.zoom + vp.current.y; + const w = n.w * vp.current.zoom; + const h = nodeDisplayH(n) * vp.current.zoom; + const pad = 6; + ctx.beginPath(); + const r = 14; + const rx = x - pad, ry = y - pad, rw = w + pad * 2, rh = h + pad * 2; + ctx.moveTo(rx + r, ry); + ctx.arcTo(rx + rw, ry, rx + rw, ry + rh, r); + ctx.arcTo(rx + rw, ry + rh, rx, ry + rh, r); + ctx.arcTo(rx, ry + rh, rx, ry, r); + ctx.arcTo(rx, ry, rx + rw, ry, r); + ctx.closePath(); + ctx.stroke(); + } + ctx.restore(); + } + + if (stillAnimating) { + // Re-arm frame loop — auto-stops when settled. + raf.current = requestAnimationFrame(render); + return; + } + // Arrow-drag rubber-band (on top of scene) + if (p) { + const s = scene.current.index.get(p.sourceId); + if (s) { + const v = vp.current; + const port = portOf(s, p.side); + const portS = { x: port.x * v.zoom + v.x, y: port.y * v.zoom + v.y }; + const curS = { x: p.cur.x * v.zoom + v.x, y: p.cur.y * v.zoom + v.y }; + // output → cursor (forward); cursor → input (backward) + drawPendingEdge(ctx, p.side === "out" ? portS : curS, p.side === "out" ? curS : portS); + } + } + if (hud.current) hud.current.textContent = armedPortRef.current + ? "Tap the target node to connect · Esc to cancel" + : `${scene.current.nodes.length} node · ${scene.current.edges.length} edge · ${Math.round(vp.current.zoom * 100)}%`; + + // Sync canvas-commands store — BottomBar zoomPercent + NodeActionBar/HoverCard position + useCanvasCommands.getState().set({ + viewport: { ...vp.current }, + nodes: scene.current.nodes, + zoomPercent: vp.current.zoom * 100, + }); + }; + const schedule = () => { if (!raf.current) raf.current = requestAnimationFrame(render); }; + + const resize = () => { + const canvas = canvasRef.current; + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + size.current = { w: rect.width, h: rect.height, dpr }; + canvas.width = Math.round(rect.width * dpr); + canvas.height = Math.round(rect.height * dpr); + schedule(); + }; + + const fit = () => { + const ns = scene.current.nodes; + const { w, h } = size.current; + if (!ns.length || !w) { vp.current = { x: 0, y: 0, zoom: 1 }; return; } + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of ns) { + minX = Math.min(minX, n.x); minY = Math.min(minY, n.y); + maxX = Math.max(maxX, n.x + n.w); maxY = Math.max(maxY, n.y + nodeDisplayH(n)); + } + const pad = 80; + const zoom = clamp(Math.min((w - pad * 2) / (maxX - minX || 1), (h - pad * 2) / (maxY - minY || 1)), 0.1, 1.4); + vp.current = { + zoom, + x: w / 2 - ((minX + maxX) / 2) * zoom, + y: h / 2 - ((minY + maxY) / 2) * zoom, + }; + }; + + // Build scene when data changes. Fit only on first load (mount); on refetch + // (e.g. node addition) don't re-fit to avoid viewport jump. + // + // AI streaming: setQueryData triggers this effect for each node/edge. Diff-aware + // buildScene marks new arrivals with enterStart=now → renderer triggers pop animation. + // If new nodes exist, arrange is called and targetX/Y is written → existing nodes + // smooth-slide to new targets, new nodes spawn at target position. Positions are + // persisted to backend via saveLayout → persist across reload. + useEffect(() => { + const prevSel = selected.current; + const prev = scene.current.nodes.length > 0 ? scene.current : null; + const now = performance.now(); + const reducedMotion = getReducedMotion(); + const { scene: newScene, newNodeIds, newEdgeIds, editedNodeIds } = buildScene(graph, prev, now, reducedMotion); + scene.current = newScene; + if (prevSel && !scene.current.index.has(prevSel)) selected.current = null; + + // Edited nodes (AI refactor / rename) → calm in-place "edited" pulse: + // triggers the existing focus halo without panning. schedule() (end of effect) draws it. + if (!reducedMotion && editedNodeIds.length > 0) { + focusHighlight.current = { nodeIds: new Set(editedNodeIds), edgeId: null, start: now, duration: 700 }; + } + + // Arrange trigger: a new node ALWAYS; a new edge only during AI generation + // or right after (covers edges from the post-stream refetch too; + // a manually drawn edge must not disturb the user's hand layout). + const aiEdgeArrived = newEdgeIds.length > 0 && prev !== null && isAiActive(); + if ((newNodeIds.length > 0 || aiEdgeArrived) && scene.current.nodes.length > 1) { + // New node/AI edge arrived → run arrange for entire scene + const pos = arrangeNodes(scene.current.nodes, scene.current.edges, "LR"); + const items: { nodeId: string; x: number; y: number }[] = []; + const newSet = new Set(newNodeIds); + for (const n of scene.current.nodes) { + const p = pos.get(n.id); + if (!p) continue; + if (newSet.has(n.id)) { + // New node: spawn at target position (pop from there) — no layout shift + n.x = p.x; n.y = p.y; + } else if (Math.abs(p.x - n.x) > 0.5 || Math.abs(p.y - n.y) > 0.5) { + // Existing node: smooth lerp from current pos to target + n.targetX = p.x; n.targetY = p.y; + } + items.push({ nodeId: n.id, x: p.x, y: p.y }); + } + // Persist to backend — positions survive page reload + onApplyLayoutRef.current?.(items); + } + schedule(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [graph]); + + // Redraw when edgePath mode changes (graph unchanged, visual only) + useEffect(() => { schedule(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [edgePath]); + + // On theme change (light↔dark) let the renderer re-read CSS variables → redraw once. + useEffect(() => { + const onTheme = () => schedule(); + window.addEventListener("solarch:theme-change", onTheme); + return () => window.removeEventListener("solarch:theme-change", onTheme); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Inline AI proposal — pending green set; on change refresh the ref + redraw. + useEffect(() => { + proposalRef.current = + proposalNodes.size > 0 || proposalEdges.size > 0 + ? { nodes: proposalNodes, edges: proposalEdges } + : null; + schedule(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [proposalNodes, proposalEdges]); + + // Redraw when bend changes (during drag setBend → store → flows here) + useEffect(() => { schedule(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [edgeBends]); + + // Redraw when canonical selection changes (any source: click, AI chip, Inspector) → + // spotlight focus set recomputes + dim transition fades in/out. Also sync the + // local selection-halo ref so the orange highlight follows non-click selection + // sources (AI chip / Inspector), keeping the halo and spotlight on the same node. + useEffect(() => { + selected.current = selectedFromStore; + schedule(); + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [selectedFromStore]); + + // Auto-arrange: run dagre → optimistically update scene → write to backend via parent callback + const doArrange = () => { + const ns = scene.current.nodes; + if (ns.length === 0) return; + // History: "before" snapshot — current position of all nodes + const before = ns.map((n) => ({ nodeId: n.id, x: n.x, y: n.y })); + const pos = arrangeNodes(ns, scene.current.edges, "LR"); + const items: { nodeId: string; x: number; y: number }[] = []; + for (const n of ns) { + const p = pos.get(n.id); + if (!p) continue; + n.x = p.x; n.y = p.y; + items.push({ nodeId: n.id, x: p.x, y: p.y }); + } + fitted.current = false; // re-fit viewport to content after arrange + schedule(); + onArrange?.(items); + if (items.length > 0) { + const beforeSnap = before; + const afterSnap = items; + useHistory.getState().record({ + undo: () => applyLayoutItems(beforeSnap), + redo: () => applyLayoutItems(afterSnap), + }); + } + }; + + // Alt+L shortcut — global (works even without canvas focus) + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === "l") { + e.preventDefault(); + doArrange(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Canvas setup + resize + interaction (imperative, outside React) + useEffect(() => { + const canvas = canvasRef.current!; + resize(); + const ro = new ResizeObserver(resize); + ro.observe(canvas); + + document.fonts.ready.then(() => schedule()); + + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const cx = e.clientX - rect.left, cy = e.clientY - rect.top; + const factor = Math.exp(-e.deltaY * 0.0015); + const z0 = vp.current.zoom; + const z1 = clamp(z0 * factor, 0.1, 4); + // Keep the world point under the cursor fixed + vp.current.x = cx - ((cx - vp.current.x) / z0) * z1; + vp.current.y = cy - ((cy - vp.current.y) / z0) * z1; + vp.current.zoom = z1; + schedule(); + }; + + // screen → world + const toWorld = (clientX: number, clientY: number) => { + const rect = canvas.getBoundingClientRect(); + return { + x: (clientX - rect.left - vp.current.x) / vp.current.zoom, + y: (clientY - rect.top - vp.current.y) / vp.current.zoom, + }; + }; + // Topmost node under cursor (back to front = drawn on top) + const hitTest = (wx: number, wy: number) => { + const ns = scene.current.nodes; + for (let i = ns.length - 1; i >= 0; i--) { + const n = ns[i]; + if (wx >= n.x && wx <= n.x + n.w && wy >= n.y && wy <= n.y + nodeDisplayH(n)) return n; + } + return null; + }; + + // Node port's SCREEN position (in on left / out on right) + const portScreen = (n: SceneNode, side: "in" | "out") => { + const pw = portOf(n, side); + return { x: pw.x * vp.current.zoom + vp.current.x, y: pw.y * vp.current.zoom + vp.current.y }; + }; + // Which port is cursor near? (out / in / null) — for drag initiation + cursor + const nearPort = (n: SceneNode, sxp: number, syp: number): "in" | "out" | null => { + const R = PORT_R + (coarseRef.current ? 28 : 20); // wider port hit-target on touch (WCAG 2.5.8) + const out = portScreen(n, "out"); + if (Math.hypot(sxp - out.x, syp - out.y) <= R) return "out"; + const inP = portScreen(n, "in"); + if (Math.hypot(sxp - inP.x, syp - inP.y) <= R) return "in"; + return null; + }; + + let panning = false; + let dragNode: SceneNode | null = null; + let dragOff = { x: 0, y: 0 }; + let dragStart: { x: number; y: number } | null = null; // "before" position for undo + let moved = false; + let lastX = 0, lastY = 0; + let bendDrag: { edgeId: string; horiz: boolean; corr: number; start: { x: number; y: number }; end: { x: number; y: number } } | null = null; + + // Multi-touch: track each active pointer by its id. Two fingers → pinch-zoom + + // two-finger pan mode. Focus point = midpoint of the two fingers; scaling + // math is identical to onWheel (the world point under the finger stays fixed). + const activePointers = new Map(); + let pinch: { lastDist: number; lastCenter: { x: number; y: number } } | null = null; + + // Touch long-press → context menu (right-click alternative). If the finger is held + // still ~500ms, AddNodeMenu opens; if it slides (pan/drag intent) it is cancelled. + let longPressTimer: ReturnType | null = null; + let pressX = 0, pressY = 0; + const clearLongPress = () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }; + + // Tap-to-connect: screen point where port-drag began — at release a movement threshold + // distinguishes "tap vs drag" (tap → ARM, drag → classic drag-to-connect). + let pendingDown = { x: 0, y: 0 }; + // Scan any node's port under the finger/cursor (not tied to hover). + const scanPort = (sxp: number, syp: number): { node: SceneNode; side: "in" | "out" } | null => { + const ns = scene.current.nodes; + for (let i = ns.length - 1; i >= 0; i--) { + const side = nearPort(ns[i], sxp, syp); + if (side) return { node: ns[i], side }; + } + return null; + }; + + // Elbow handle hit-test: is cursor (in screen coords) near a bend handle? + // Corridor offset applied the same way as drawing (so the handle stays on the wire). + const hitBendHandle = (sxp: number, syp: number) => { + if (edgePathRef.current !== "elbow") return null; + const sc = scene.current; + const v = vp.current; + for (const e of sc.edges) { + const a = sc.index.get(e.source); const b = sc.index.get(e.target); + if (!a || !b) continue; + const g = elbowGeom(a, b, edgeBendsRef.current[e.id] ?? 0.5); + if (!g) continue; + const corr = corridorCache.current.map?.get(e.id) ?? 0; + const hx = (g.handle.x + (g.horiz ? corr : 0)) * v.zoom + v.x; + const hy = (g.handle.y + (g.horiz ? 0 : corr)) * v.zoom + v.y; + if (Math.hypot(sxp - hx, syp - hy) <= BEND_HANDLE_R + (coarseRef.current ? 16 : 4)) { + return { edgeId: e.id, horiz: g.horiz, corr, start: g.start, end: g.end }; + } + } + return null; + }; + + // Point → segment perpendicular distance (screen px) + const pointToSegDist = (px: number, py: number, x1: number, y1: number, x2: number, y2: number) => { + const dx = x2 - x1, dy = y2 - y1; + const len2 = dx * dx + dy * dy; + if (len2 === 0) return Math.hypot(px - x1, py - y1); + let t = ((px - x1) * dx + (py - y1) * dy) / len2; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)); + }; + + // Edge hit-test — works in all modes (elbow: middle segment, bezier: sampling, straight: full path) + const hitEdge = (sxp: number, syp: number): string | null => { + const sc = scene.current; + const v = vp.current; + const mode = edgePathRef.current; + const THRESHOLD = coarseRef.current ? 18 : 8; // wider edge hit-target on touch + let best: string | null = null, bestD = Infinity; + for (const e of sc.edges) { + const a = sc.index.get(e.source); const b = sc.index.get(e.target); + if (!a || !b) continue; + + const portOutW = portOf(a, "out"); + const portInW = portOf(b, "in"); + const STUB = STUB_LEN_WORLD * v.zoom; + const portOutS = { x: portOutW.x * v.zoom + v.x, y: portOutW.y * v.zoom + v.y }; + const portInS = { x: portInW.x * v.zoom + v.x, y: portInW.y * v.zoom + v.y }; + const stubOutS = { x: portOutS.x + STUB, y: portOutS.y }; + const stubInS = { x: portInS.x - STUB, y: portInS.y }; + + let d = Infinity; + + if (mode === "elbow") { + const g = elbowGeom(a, b, edgeBendsRef.current[e.id] ?? 0.5); + if (!g) continue; + const corr = corridorCache.current.map?.get(e.id) ?? 0; + const hxc = g.handle.x + (g.horiz ? corr : 0); + const hyc = g.handle.y + (g.horiz ? 0 : corr); + const c1 = g.horiz ? { x: hxc, y: g.start.y } : { x: g.start.x, y: hyc }; + const c2 = g.horiz ? { x: hxc, y: g.end.y } : { x: g.end.x, y: hyc }; + const c1s = { x: c1.x * v.zoom + v.x, y: c1.y * v.zoom + v.y }; + const c2s = { x: c2.x * v.zoom + v.x, y: c2.y * v.zoom + v.y }; + d = pointToSegDist(sxp, syp, c1s.x, c1s.y, c2s.x, c2s.y); + } else if (mode === "straight") { + // Only stubOut→stubIn (port stubs override port hover, not included) + d = pointToSegDist(sxp, syp, stubOutS.x, stubOutS.y, stubInS.x, stubInS.y); + } else { // bezier + const dx = stubInS.x - stubOutS.x; + const dy = stubInS.y - stubOutS.y; + const horiz = Math.abs(dx) >= Math.abs(dy); + const off = Math.min(Math.max(Math.abs(horiz ? dx : dy) * 0.5, 24), 220); + const c1x = horiz ? stubOutS.x + off : stubOutS.x; + const c1y = horiz ? stubOutS.y : stubOutS.y + off; + const c2x = horiz ? stubInS.x - off : stubInS.x; + const c2y = horiz ? stubInS.y : stubInS.y - off; + // Only bezier curve (not stubs — causes noise near ports) + for (let i = 0; i <= 16; i++) { + const t = i / 16, mt = 1 - t; + const bx = mt*mt*mt*stubOutS.x + 3*mt*mt*t*c1x + 3*mt*t*t*c2x + t*t*t*stubInS.x; + const by = mt*mt*mt*stubOutS.y + 3*mt*mt*t*c1y + 3*mt*t*t*c2y + t*t*t*stubInS.y; + d = Math.min(d, Math.hypot(sxp - bx, syp - by)); + } + } + + if (d < THRESHOLD && d < bestD) { bestD = d; best = e.id; } + } + return best; + }; + + const onDown = (e: PointerEvent) => { + const rect = canvas.getBoundingClientRect(); + const sxp = e.clientX - rect.left, syp = e.clientY - rect.top; + const wp = toWorld(e.clientX, e.clientY); + canvas.setPointerCapture(e.pointerId); + moved = false; + + // Multi-touch intent gate: when the second finger lands, switch to pinch mode and + // cancel the single-finger operation the FIRST finger started (node-drag / pan / edge / + // bend) — prevents Excalidraw's spurious-stroke bug. + activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (activePointers.size >= 2) { + clearLongPress(); + if (dragNode && dragStart) { dragNode.x = dragStart.x; dragNode.y = dragStart.y; } // undo nudge + dragNode = null; dragStart = null; pending.current = null; bendDrag = null; panning = false; + armedPortRef.current = null; + useCanvasCommands.getState().set({ isDragging: false }); + const p = [...activePointers.values()]; + pinch = { + lastDist: Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y) || 1, + lastCenter: { x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 }, + }; + canvas.style.cursor = "default"; + schedule(); + return; + } + + // Tap-to-connect COMPLETION: while a port is armed, the next tap picks the target + // (port or node body). Empty/same tap = cancel. Drag-free single-pointer path. + if (armedPortRef.current) { + const armed = armedPortRef.current; + armedPortRef.current = null; + const tgt = scanPort(sxp, syp)?.node ?? hitTest(wp.x, wp.y); + if (tgt && tgt.id !== armed.nodeId) { + onEdgeDropRef.current?.(armed.nodeId, armed.side, wp, { x: sxp, y: syp }, tgt.id); + hapticConfirm(); + } + canvas.style.cursor = "default"; + schedule(); + return; + } + + // Touch long-press → context menu (finger held ~500ms ⇒ AddNodeMenu). + // The single-finger logic below (pan/drag) is still set up; if long-press fires it is cancelled. + if (e.pointerType === "touch") { + pressX = e.clientX; pressY = e.clientY; + clearLongPress(); + longPressTimer = setTimeout(() => { + longPressTimer = null; + if (dragNode && dragStart) { dragNode.x = dragStart.x; dragNode.y = dragStart.y; } // undo nudge + dragNode = null; dragStart = null; pending.current = null; panning = false; + useCanvasCommands.getState().set({ isDragging: false }); + try { canvas.releasePointerCapture(e.pointerId); } catch { /* ignore */ } + hapticConfirm(); + const r = canvas.getBoundingClientRect(); + onContextMenu?.(toWorld(pressX, pressY), { x: pressX - r.left, y: pressY - r.top }); + canvas.style.cursor = "default"; + schedule(); + }, 500); + } + + // 0) Elbow bend handle (before node hit-test, before port-drag) + const bh = hitBendHandle(sxp, syp); + if (bh) { + clearLongPress(); // bend handle pressed: drag intent → cancel menu open + bendDrag = bh; + canvas.style.cursor = bh.horiz ? "ew-resize" : "ns-resize"; + return; + } + + // 1) Start port-drag. Mouse: fast path of the hovered port; otherwise (incl. touch) + // scan any port under the finger → drag-to-connect works on touch. + const hov = hovered.current ? scene.current.index.get(hovered.current) : null; + const hovSide = hov ? nearPort(hov, sxp, syp) : null; + const port = hovSide && hov ? { node: hov, side: hovSide } : scanPort(sxp, syp); + if (port) { + clearLongPress(); // port pressed: connect intent → cancel long-press menu + pending.current = { sourceId: port.node.id, side: port.side, cur: wp }; + pendingDown = { x: sxp, y: syp }; // read at release to distinguish tap/drag + hovered.current = port.node.id; + canvas.style.cursor = "crosshair"; + schedule(); + return; + } + + const hit = hitTest(wp.x, wp.y); + if (hit) { + dragNode = hit; + dragOff = { x: wp.x - hit.x, y: wp.y - hit.y }; + dragStart = { x: hit.x, y: hit.y }; // undo "before" snapshot + selected.current = hit.id; + selectedEdge.current = null; // unselect edge when selecting node + // Manual canvas selection wins over an active instruct narration spotlight — + // clears it so selection becomes the sole spotlight source (no stale dim). + instructFocusId.current = null; + selectNode(hit.id); // forward to sidebar Inspector + if (e.pointerType === "touch") hapticTap(); // selection tick (touch) + canvas.style.cursor = "grabbing"; + // ActionBar/HoverCard guard — true only during node drag (not pending edge / bend drag) + useCanvasCommands.getState().set({ isDragging: true }); + } else { + // Empty area: try edge select first + const edgeHit = hitEdge(sxp, syp); + if (edgeHit) { + selectedEdge.current = edgeHit; + selected.current = null; + selectNode(null); // node deselect → inspector closes + canvas.style.cursor = "pointer"; + } else { + panning = true; + selected.current = null; + selectedEdge.current = null; + selectNode(null); + lastX = e.clientX; lastY = e.clientY; + canvas.style.cursor = "grabbing"; + } + } + schedule(); + }; + + const onMove = (e: PointerEvent) => { + // Pinch-zoom + two-finger pan (focus = finger midpoint, math same as onWheel) + if (pinch && activePointers.has(e.pointerId)) { + activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (activePointers.size >= 2) { + const p = [...activePointers.values()]; + const dist = Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y) || 1; + const center = { x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 }; + const rect = canvas.getBoundingClientRect(); + const cx = center.x - rect.left, cy = center.y - rect.top; + const z0 = vp.current.zoom; + const z1 = clamp(z0 * (dist / pinch.lastDist), 0.1, 4); + // scale around the focus point (point under the finger stays fixed) + vp.current.x = cx - ((cx - vp.current.x) / z0) * z1; + vp.current.y = cy - ((cy - vp.current.y) / z0) * z1; + vp.current.zoom = z1; + // two-finger pan: viewport shifts as the midpoint moves + vp.current.x += center.x - pinch.lastCenter.x; + vp.current.y += center.y - pinch.lastCenter.y; + pinch.lastDist = dist; + pinch.lastCenter = center; + schedule(); + } + return; + } + // Long-press: if the finger slides (pan/drag intent) cancel the pending long-press + if (longPressTimer && Math.hypot(e.clientX - pressX, e.clientY - pressY) > 10) clearLongPress(); + if (bendDrag) { + const wp = toWorld(e.clientX, e.clientY); + const dx = bendDrag.end.x - bendDrag.start.x; + const dy = bendDrag.end.y - bendDrag.start.y; + // Corridor offset is added in drawing, so it's subtracted in drag — the handle tracks the cursor. + const ratio = bendDrag.horiz + ? (wp.x - bendDrag.corr - bendDrag.start.x) / (dx || 1) + : (wp.y - bendDrag.corr - bendDrag.start.y) / (dy || 1); + setBend(bendDrag.edgeId, ratio); // store does clamp01 + return; // re-render triggered via useEffect[edgeBends] + } + if (pending.current) { + pending.current.cur = toWorld(e.clientX, e.clientY); + schedule(); + } else if (dragNode) { + const wp = toWorld(e.clientX, e.clientY); + // 8px snap-to-grid — nodes align neatly; free when Alt is held. + const SNAP = 8; + const free = e.altKey; + const nx = wp.x - dragOff.x, ny = wp.y - dragOff.y; + dragNode.x = free ? Math.round(nx) : Math.round(nx / SNAP) * SNAP; + dragNode.y = free ? Math.round(ny) : Math.round(ny / SNAP) * SNAP; + moved = true; + schedule(); + } else if (panning) { + vp.current.x += e.clientX - lastX; + vp.current.y += e.clientY - lastY; + lastX = e.clientX; lastY = e.clientY; + schedule(); + } else { + // hover: cursor + port + edge handle/path + const rect = canvas.getBoundingClientRect(); + const sxp = e.clientX - rect.left, syp = e.clientY - rect.top; + const wp = toWorld(e.clientX, e.clientY); + let hit = hitTest(wp.x, wp.y); + if (!hit && hovered.current) { + const cur = scene.current.index.get(hovered.current); + if (cur && nearPort(cur, sxp, syp) !== null) hit = cur; + } + // Edge hit only when no node (node takes priority) + const edgeHit = !hit ? hitEdge(sxp, syp) : null; + const bhHover = !hit && edgeHit ? hitBendHandle(sxp, syp) : null; + + const prevHover = hovered.current; + const prevEdgeHover = hoveredEdge.current; + hovered.current = hit ? hit.id : null; + hoveredEdge.current = edgeHit; + + const onP = hit ? nearPort(hit, sxp, syp) !== null : false; + canvas.style.cursor = + bhHover ? (bhHover.horiz ? "ew-resize" : "ns-resize") : + onP ? "crosshair" : + hit ? "grab" : + edgeHit ? "pointer" : "default"; + + if (hovered.current !== prevHover) { + useSelection.getState().setHovered(hovered.current); + } + if (hovered.current !== prevHover || hoveredEdge.current !== prevEdgeHover) schedule(); + } + }; + + const onUp = (e: PointerEvent) => { + try { canvas.releasePointerCapture(e.pointerId); } catch { /* ignore */ } + activePointers.delete(e.pointerId); + clearLongPress(); + // Skip drag-commit logic during pinch; end pinch when fewer than two fingers remain. + if (pinch) { + if (activePointers.size < 2) { pinch = null; panning = false; canvas.style.cursor = "default"; } + return; + } + if (bendDrag) { + bendDrag = null; + canvas.style.cursor = "default"; + return; + } + const rect = canvas.getBoundingClientRect(); + if (pending.current) { + const upX = e.clientX - rect.left, upY = e.clientY - rect.top; + const wp = toWorld(e.clientX, e.clientY); + const { sourceId, side } = pending.current; + pending.current = null; + hovered.current = null; + // TAP (motionless release) → ARM the source (tap-to-connect; drag-free, WCAG 2.5.7). + if (Math.hypot(upX - pendingDown.x, upY - pendingDown.y) < 8) { + armedPortRef.current = { nodeId: sourceId, side }; + hapticTap(); + canvas.style.cursor = "default"; + schedule(); + return; + } + // DRAG → classic drag-to-connect. Release over the source = cancel. + const dropTarget = hitTest(wp.x, wp.y); + if (dropTarget && dropTarget.id === sourceId) { + canvas.style.cursor = "default"; + schedule(); + return; + } + onEdgeDropRef.current?.(sourceId, side, wp, { x: upX, y: upY }, dropTarget ? dropTarget.id : undefined); + canvas.style.cursor = "default"; + schedule(); + return; + } + if (dragNode && moved && onNodeMoved) { + onNodeMoved(dragNode.id, dragNode.x, dragNode.y); // save new position + // History: single node move — DON'T record if snap results in equal positions + if (dragStart) { + const changed = dragStart.x !== dragNode.x || dragStart.y !== dragNode.y; + if (changed) { + const beforeSnap = [{ nodeId: dragNode.id, x: dragStart.x, y: dragStart.y }]; + const afterSnap = [{ nodeId: dragNode.id, x: dragNode.x, y: dragNode.y }]; + useHistory.getState().record({ + undo: () => applyLayoutItems(beforeSnap), + redo: () => applyLayoutItems(afterSnap), + }); + } + } + } + if (dragNode) { + // ActionBar/HoverCard visible again after drag ends + useCanvasCommands.getState().set({ isDragging: false }); + } + dragNode = null; + dragStart = null; + panning = false; + canvas.style.cursor = "default"; + }; + + const onCtx = (e: MouseEvent) => { + e.preventDefault(); + const rect = canvas.getBoundingClientRect(); + onContextMenu?.(toWorld(e.clientX, e.clientY), { x: e.clientX - rect.left, y: e.clientY - rect.top }); + }; + + // Double-click → select node + open EditorModal + auto-focus Inspector's first input. + // openEditor is atomic: editorNodeId + selectedNodeId + editingNodeId set together. + const onDblClick = (e: MouseEvent) => { + const wp = toWorld(e.clientX, e.clientY); + const hit = hitTest(wp.x, wp.y); + if (hit) { + e.preventDefault(); + selected.current = hit.id; + useSelection.getState().openEditor(hit.id); + schedule(); + } + }; + + // ⌘Z/⌘⇧Z + Delete global hotkeys (F2 rename in AppShell) + const onKeyDown = (e: KeyboardEvent) => { + const t = e.target as HTMLElement; + const inForm = t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable; + + // Escape → cancel the armed tap-to-connect source + if (e.key === "Escape" && armedPortRef.current) { + armedPortRef.current = null; + schedule(); + return; + } + + // Delete / Backspace → delete selected edge + if ((e.key === "Delete" || e.key === "Backspace") && !inForm && selectedEdge.current) { + e.preventDefault(); + const eid = selectedEdge.current; + selectedEdge.current = null; + schedule(); + onEdgeDeleteRef.current?.(eid); + return; + } + + // ⌘Z / Ctrl+Z → undo, ⌘+Shift+Z → redo + const mod = e.metaKey || e.ctrlKey; + if (mod && e.key.toLowerCase() === "z" && !inForm) { + e.preventDefault(); + if (e.shiftKey) { + useHistory.getState().redo(); + } else { + useHistory.getState().undo(); + } + } + }; + + canvas.addEventListener("wheel", onWheel, { passive: false }); + canvas.addEventListener("pointerdown", onDown); + canvas.addEventListener("pointermove", onMove); + canvas.addEventListener("pointerup", onUp); + canvas.addEventListener("pointercancel", onUp); // pointer cancel → prevent state leak + canvas.addEventListener("contextmenu", onCtx); + canvas.addEventListener("dblclick", onDblClick); + window.addEventListener("keydown", onKeyDown); + + return () => { + ro.disconnect(); + canvas.removeEventListener("wheel", onWheel); + canvas.removeEventListener("pointerdown", onDown); + canvas.removeEventListener("pointermove", onMove); + canvas.removeEventListener("pointerup", onUp); + canvas.removeEventListener("pointercancel", onUp); + canvas.removeEventListener("contextmenu", onCtx); + canvas.removeEventListener("dblclick", onDblClick); + window.removeEventListener("keydown", onKeyDown); + if (raf.current) { cancelAnimationFrame(raf.current); raf.current = 0; } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Soft pan+zoom to a node + 600ms orange highlight. For AI chat NodeChip. + // + // Two contexts: + // - selection / chip click (default): keeps the existing zoom-in behaviour and + // centers the node in the viewport. Does NOT touch the spotlight source. + // - instruct narration (opts.instruct): the node is the active narration subject. + // (a) zoom a touch LOWER (zoom-out) so the node + its neighbours fit, (b) shift + // the node UP into the clean area above the bottom explanation panel (reserve the + // bottom `reserveBottom` fraction of the viewport), and (c) make this node the + // spotlight source so it + 1-hop neighbours stay lit while the rest dims. + const doFocusNode = (id: string, opts?: { zoom?: boolean; instruct?: boolean; reserveBottom?: number }) => { + const n = scene.current.index.get(id); + if (!n) return; + const cx = n.x + n.w / 2; + const cy = n.y + nodeDisplayH(n) / 2; + + if (opts?.instruct) { + // Instruct spotlight source — recomputed in ensureFocusSet (sig folds this id). + instructFocusId.current = id; + // Zoom-out vs. the selection focus: cap lower + scale down so the focused node + // plus its 1-hop neighbourhood comfortably fit on screen (no edge-to-edge crop). + const targetZoom = clamp(Math.min(vp.current.zoom, 1.0) * 0.85, 0.45, 1.1); + // Reserve the bottom `reserveBottom` fraction for the explanation panel; center + // the node vertically in the remaining clean band (top → reserve line). + const reserve = clamp(opts.reserveBottom ?? 0.42, 0, 0.7); + const cleanH = size.current.h * (1 - reserve); + vpTarget.current = { + zoom: targetZoom, + x: size.current.w / 2 - cx * targetZoom, + y: cleanH / 2 - cy * targetZoom, + }; + } else { + const targetZoom = opts?.zoom + ? clamp(Math.max(vp.current.zoom, 1.0) * 1.25, 0.5, 1.8) + : vp.current.zoom; + vpTarget.current = { + zoom: targetZoom, + x: size.current.w / 2 - cx * targetZoom, + y: size.current.h / 2 - cy * targetZoom, + }; + } + focusHighlight.current = { nodeIds: new Set([id]), edgeId: null, start: performance.now(), duration: 600 }; + schedule(); + }; + + // Clear instruct-narration spotlight source. The selection spotlight (if any) + // resumes; otherwise dim fades back to 0. ensureFocusSet picks this up via its sig. + const doClearInstructFocus = () => { + if (instructFocusId.current === null) return; + instructFocusId.current = null; + schedule(); + }; + + const doFocusEdge = (id: string) => { + const e = scene.current.edges.find((x) => x.id === id); + if (!e) return; + const a = scene.current.index.get(e.source); + const b = scene.current.index.get(e.target); + if (!a || !b) return; + // Fit bounds so both edge endpoints are visible (don't zoom — only pan) + const ax = a.x + a.w / 2, ay = a.y + nodeDisplayH(a) / 2; + const bx = b.x + b.w / 2, by = b.y + nodeDisplayH(b) / 2; + const cx = (ax + bx) / 2, cy = (ay + by) / 2; + vpTarget.current = { + zoom: vp.current.zoom, + x: size.current.w / 2 - cx * vp.current.zoom, + y: size.current.h / 2 - cy * vp.current.zoom, + }; + focusHighlight.current = { + nodeIds: new Set([e.source, e.target]), + edgeId: id, + start: performance.now(), + duration: 600, + }; + schedule(); + }; + + // Register callbacks to BottomBar canvas-commands store — mount/unmount cycle + useEffect(() => { + useCanvasCommands.getState().set({ + fit: () => { fit(); schedule(); }, + zoomIn: () => { vp.current.zoom = clamp(vp.current.zoom * 1.2, 0.1, 4); schedule(); }, + zoomOut: () => { vp.current.zoom = clamp(vp.current.zoom / 1.2, 0.1, 4); schedule(); }, + arrange: doArrange, + undo: onUndoClick, + redo: onRedoClick, + focusNode: doFocusNode, + focusEdge: doFocusEdge, + clearInstructFocus: doClearInstructFocus, + }); + return () => { + useCanvasCommands.getState().set({ + fit: null, zoomIn: null, zoomOut: null, arrange: null, undo: null, redo: null, + focusNode: null, focusEdge: null, clearInstructFocus: null, + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Keep store up-to-date when canUndo/canRedo changes + useEffect(() => { + useCanvasCommands.getState().set({ canUndo, canRedo }); + }, [canUndo, canRedo]); + + return ( +
    + {/* Canvas is decorative (aria-hidden) — semantics come from the invisible DOM mirror below. */} +