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 @@
[](https://app.solarch.dev)
[](https://solarch.dev)
+[](docs/getting-started.md)
[](./LICENSE)
[](https://github.com/solarch-dev/solarch/stargazers)
[](https://github.com/solarch-dev/solarch/commits/main)
-[](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