──────
Build and Push Docker Images / detect-changes (push) Successful in 11s
Build and Push Docker Images / build-auth-api (push) Successful in 1m0s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m1s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m5s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m13s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s

### Summary of Changes
  1. Documentation Consolidation:
      • Relocated the docs/ folder and all repository .md files (README.md, DEPLOYMENT.md, DOCKER_README.md,
      DOMAINS_README.md, GETTING_STARTED.md, CI_CD_PORTAINER_GUIDE.md, ADMIN_DEPLOYMENT_SUMMARY.md,
      SECURITY_PRIVATE_FILES.md, handoff.md, azure-project-plan.md, plus service-specific READMEs) into .
      • Updated document links in README.md.
  2. AI Agent Project Map:
      • Created PROJECT_MAP.md in the project root containing complete architecture diagrams, service specs, ports, DB
      schemas, environment variables, and developer workflows.
      • Added PROJECT_MAP.md to .gitignore.
  3. Monitoring UI & Infrastructure Integration:
      • Added monitoring_ui service to docker-compose.yml (port 5175).
      • Configured Gitea workflow docker-build.yml for monitoring_ui builds.
      • Added CORS middleware headers in index.js.
      • Added nginx.conf copy step in Dockerfile.

  ──────
  ### Recommended Commit Message

    docs: consolidate project documentation into monitoring_ui and add agent project map

    - Move all markdown documentation files and docs/ folder into monitoring_ui/docs/
    - Update documentation hub index (monitoring_ui/docs/README.md) with relative paths
    - Generate comprehensive AI agent reference map (PROJECT_MAP.md) in repository root
    - Add PROJECT_MAP.md and monitoring_ui build output/env overrides to .gitignore
    - Integrate monitoring_ui service into docker-compose.yml and Gitea CI workflow
    - Configure CORS handling in employee_api for monitoring dashboard access
    ──────
  ### Alternative Short Commit Message

    refactor(docs): move docs to monitoring_ui and create AI agent PROJECT_MAP.md
This commit is contained in:
Sven laptop
2026-09-20 23:33:55 +02:00
parent f935bbeb8c
commit 635a130f0b
195 changed files with 28309 additions and 957 deletions
@@ -0,0 +1,4 @@
{
"name": "shared-references",
"version": "0.0.1"
}
@@ -0,0 +1,572 @@
<!-- azure-cor-disclaimer -->
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
# Project Architecture
> Best practices for structuring an Azure-centric project with built-in testability.
---
## Core Principles
1. **Service boundary isolation** — Every Azure service behind dedicated module with interface. Never scatter SDK calls across handlers.
2. **Dependency injection** — Services injectable. Handlers receive deps, not import singletons. Swap real for mocks in tests.
3. **Environment-driven config** — Same code for mocks, emulators, Azure — switched by env vars.
4. **Monorepo by default** — Frontend, backend, shared types in one repo with clear boundaries.
5. **Contracts first** — Shared types/schemas in `shared/` dir. API contracts defined before implementation.
6. **One function per file** — File name matches function name. Each independently testable.
7. **Tests next to source** — Test directory mirrors source structure.
---
## Canonical Project Structures
> **📁 Naming the service folders.** The folder names in the trees below (`functions`, `web`) are **role placeholders**. When the project has a clear product name, **prefer domain-specific names for the deployable apps** — derive a kebab-case slug from the product name and add a role suffix:
>
> - **Functions backend** → `<project>-api` (e.g. `office-compliance-api`)
> - **Frontend** → `<project>-<type>`, where `<type>` fits the app — `-portal`, `-app`, or `-web` (e.g. `office-compliance-portal`)
> - **Shared package** → keep the generic `shared/` (it is internal, never a deployed app)
>
> This is a **SHOULD**, not a mandate: fall back to the generic `functions`/`web` only when there is no clear project name (e.g. generic internal tooling), or when the workspace already has a structure to follow. Whatever names you pick, apply them **consistently everywhere** — npm `workspaces`, `cd` commands, tsconfig `rootDir`, and the computed `main` field (e.g. with `rootDir: ".."`, `<project>-api` handlers compile to `dist/<project>-api/src/functions/*.js`). Imports of the shared package stay `../shared/...`. The plan's Project Structure section is the source of truth; the trees below use generic names purely as illustration.
> **📁 Naming the service folders.** The folder names in the trees below (`functions`, `web`) are **role placeholders**. When the project has a clear product name, **prefer domain-specific names for the deployable apps** — derive a kebab-case slug from the product name and add a role suffix:
>
> - **Functions backend** → `<project>-api` (e.g. `office-compliance-api`)
> - **Frontend** → `<project>-<type>`, where `<type>` fits the app — `-portal`, `-app`, or `-web` (e.g. `office-compliance-portal`)
> - **Shared package** → keep the generic `shared/` (it is internal, never a deployed app)
>
> This is a **SHOULD**, not a mandate: fall back to the generic `functions`/`web` only when there is no clear project name (e.g. generic internal tooling), or when the workspace already has a structure to follow. Whatever names you pick, apply them **consistently everywhere** — npm `workspaces`, `cd` commands, tsconfig `rootDir`, and the computed `main` field (e.g. with `rootDir: ".."`, `<project>-api` handlers compile to `dist/<project>-api/src/functions/*.js`). Imports of the shared package stay `../shared/...`. The plan's Project Structure section is the source of truth; the trees below use generic names purely as illustration.
### TypeScript — SPA + Azure Functions
```
project-root/
├── .azure/
│ └── project-plan.md ← Project plan (source of truth)
├── .env.example ← Connection string template (checked in)
├── .env ← Actual values (gitignored)
├── .gitignore
├── package.json ← Root workspace config
├── services/
│ ├── functions/ ← Azure Functions project
│ │ ├── host.json
│ │ ├── local.settings.json ← Functions env config (gitignored)
│ │ ├── package.json ← Backend dependencies
│ │ ├── tsconfig.json
│ │ ├── vitest.config.ts ← Test runner config
│ │ ├── openapi.yaml ← API contract
│ │ ├── src/
│ │ │ ├── functions/ ← Function handlers (one per file)
│ │ │ │ ├── getItems.ts
│ │ │ │ ├── createItem.ts
│ │ │ │ ├── getItemById.ts
│ │ │ │ ├── updateItem.ts
│ │ │ │ ├── deleteItem.ts
│ │ │ │ ├── health.ts
│ │ │ │ └── openapi.ts
│ │ │ ├── services/ ← Service abstraction layer
│ │ │ │ ├── interfaces/
│ │ │ │ │ ├── IStorageService.ts
│ │ │ │ │ ├── IDatabaseService.ts
│ │ │ │ │ └── ICacheService.ts
│ │ │ │ ├── storage.ts
│ │ │ │ ├── database.ts
│ │ │ │ ├── cache.ts
│ │ │ │ ├── config.ts ← Config loader + env validation
│ │ │ │ └── registry.ts ← Service factory / DI registry
│ │ │ ├── errors/
│ │ │ │ ├── AppError.ts ← Base error class
│ │ │ │ ├── errorTypes.ts ← NotFoundError, ValidationError, etc.
│ │ │ │ └── errorHandler.ts ← Global error handler
│ │ │ ├── middleware/
│ │ │ │ ├── requestLogger.ts
│ │ │ │ └── validateRequest.ts
│ │ │ └── logger.ts ← Structured logger (pino)
│ │ ├── tests/
│ │ │ ├── fixtures/ ← Mock data (JSON files)
│ │ │ │ ├── items.json
│ │ │ │ └── users.json
│ │ │ ├── mocks/ ← Mock service implementations
│ │ │ │ ├── mockStorage.ts
│ │ │ │ ├── mockDatabase.ts
│ │ │ │ └── mockCache.ts
│ │ │ ├── services/
│ │ │ │ ├── config.test.ts
│ │ │ │ ├── storage.test.ts
│ │ │ │ ├── database.test.ts
│ │ │ │ └── registry.test.ts
│ │ │ ├── functions/
│ │ │ │ ├── getItems.test.ts
│ │ │ │ ├── createItem.test.ts
│ │ │ │ ├── getItemById.test.ts
│ │ │ │ ├── health.test.ts
│ │ │ │ └── openapi.test.ts
│ │ │ ├── errors/
│ │ │ │ └── errorHandler.test.ts
│ │ │ └── validation/
│ │ │ └── itemSchema.test.ts
│ ├── web/ ← Frontend application
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ ├── vite.config.ts ← Dev proxy to Functions
│ │ ├── index.html
│ │ └── src/
│ │ ├── App.tsx
│ │ ├── main.tsx
│ │ ├── api/
│ │ │ └── client.ts ← Typed API client
│ │ ├── components/
│ │ ├── pages/
│ │ └── hooks/
│ └── shared/ ← Shared types and schemas
│ ├── package.json
│ ├── types/
│ │ ├── index.ts
│ │ ├── entities.ts ← Entity types (shared FE + BE)
│ │ └── api.ts ← Response contracts + ErrorCode union
│ └── schemas/
│ └── validation.ts ← Zod schemas + inferred request types
└── data/ ← Docker volume mounts (gitignored)
```
### Shared Types — Single Source of Truth for Request Types
> ⚠️ **CRITICAL: Do NOT define request types in BOTH `types/api.ts` AND `schemas/validation.ts`.** Causes duplicate export errors.
>
> With Zod, `z.infer<typeof schema>` types ARE canonical request types:
>
> | File | Contains | Does NOT contain |
> |------|----------|-----------------|
> | `types/entities.ts` | Entity interfaces (User, Photo, etc.) | — |
> | `types/api.ts` | Response types, `ErrorCode` union, `ErrorResponse` | Request types (LoginRequest, etc.) |
> | `schemas/validation.ts` | Zod schemas + `z.infer` request types | Response types |
> | `index.ts` | `export * from` all three files | — |
>
> This ensures `export * from './types/api.js'` and `export * from './schemas/validation.js'` never export the same name.
### TypeScript — API Only
```
project-root/
├── .azure/
│ └── project-plan.md
├── .env.example
├── .gitignore
├── package.json
├── services/
│ ├── functions/
│ │ ├── host.json
│ │ ├── local.settings.json
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ ├── vitest.config.ts
│ │ ├── openapi.yaml
│ │ ├── src/
│ │ │ ├── functions/
│ │ │ ├── services/
│ │ │ ├── errors/
│ │ │ ├── middleware/
│ │ │ └── logger.ts
│ │ ├── tests/
│ │ │ ├── fixtures/
│ │ │ ├── mocks/
│ │ │ ├── services/
│ │ │ ├── functions/
│ │ │ └── errors/
│ └── shared/
│ ├── types/
│ └── schemas/
└── data/
```
### Python — SPA + Azure Functions
```
project-root/
├── .azure/
│ └── project-plan.md
├── .env.example
├── .gitignore
├── services/
│ ├── functions/ ← Azure Functions Python project
│ │ ├── host.json
│ │ ├── local.settings.json
│ │ ├── pyproject.toml ← Python project config
│ │ ├── pytest.ini ← Test config
│ │ ├── openapi.yaml
│ │ ├── function_app.py ← Function registration
│ │ ├── services/
│ │ │ ├── __init__.py
│ │ │ ├── interfaces.py ← ABC / Protocol definitions
│ │ │ ├── storage.py
│ │ │ ├── database.py
│ │ │ ├── cache.py
│ │ │ ├── config.py ← Config loader + validation
│ │ │ └── registry.py ← Service factory
│ │ ├── errors/
│ │ │ ├── __init__.py
│ │ │ ├── app_error.py
│ │ │ ├── error_types.py
│ │ │ └── error_handler.py
│ │ ├── middleware/
│ │ │ ├── __init__.py
│ │ │ ├── request_logger.py
│ │ │ └── validate_request.py
│ │ ├── logger.py ← structlog setup
│ │ └── tests/
│ │ ├── conftest.py ← Pytest fixtures (mock services)
│ │ ├── fixtures/
│ │ │ ├── items.json
│ │ │ └── users.json
│ │ ├── test_config.py
│ │ ├── test_storage.py
│ │ ├── test_database.py
│ │ ├── test_get_items.py
│ │ ├── test_create_item.py
│ │ ├── test_error_handler.py
│ │ ├── test_health.py
│ │ └── test_validation.py
│ ├── web/ ← Frontend
│ │ └── (same as TypeScript)
│ └── shared/
│ ├── types.py ← Pydantic models
│ └── validation.py ← Validation schemas
└── data/
```
### C# (.NET 10) — SPA + Azure Functions
```
project-root/
├── .azure/
│ └── project-plan.md
├── .env.example
├── .gitignore
├── ProjectName.sln
├── services/
│ ├── Functions/ ← Azure Functions isolated worker
│ │ ├── Functions.csproj
│ │ ├── host.json
│ │ ├── local.settings.json
│ │ ├── Program.cs ← DI registration + startup
│ │ ├── openapi.yaml
│ │ ├── Functions/ ← Function handlers
│ │ │ ├── GetItems.cs
│ │ │ ├── CreateItem.cs
│ │ │ ├── GetItemById.cs
│ │ │ ├── Health.cs
│ │ │ └── OpenApi.cs
│ │ ├── Services/
│ │ │ ├── Interfaces/
│ │ │ │ ├── IStorageService.cs
│ │ │ │ ├── IDatabaseService.cs
│ │ │ │ └── ICacheService.cs
│ │ │ ├── StorageService.cs
│ │ │ ├── DatabaseService.cs
│ │ │ ├── CacheService.cs
│ │ │ └── Config.cs
│ │ ├── Errors/
│ │ │ ├── AppException.cs
│ │ │ ├── ErrorTypes.cs
│ │ │ └── ErrorHandler.cs
│ │ ├── Middleware/
│ │ │ ├── RequestLogger.cs
│ │ │ └── ValidateRequest.cs
│ ├── Functions.Tests/ ← xUnit test project
│ │ ├── Functions.Tests.csproj
│ │ ├── Fixtures/
│ │ │ └── ItemFixtures.cs
│ │ ├── Mocks/
│ │ │ ├── MockStorageService.cs
│ │ │ ├── MockDatabaseService.cs
│ │ │ └── MockCacheService.cs
│ │ ├── Services/
│ │ │ ├── ConfigTests.cs
│ │ │ └── StorageTests.cs
│ │ ├── Functions/
│ │ │ ├── GetItemsTests.cs
│ │ │ ├── CreateItemTests.cs
│ │ │ └── HealthTests.cs
│ │ ├── Errors/
│ │ │ └── ErrorHandlerTests.cs
│ │ └── Validation/
│ │ └── ItemValidatorTests.cs
│ ├── Shared/
│ │ ├── Shared.csproj
│ │ ├── Models/
│ │ │ ├── Item.cs
│ │ │ └── ApiContracts.cs
│ │ └── Validators/
│ │ └── ItemValidator.cs ← FluentValidation
│ └── Web/ ← Frontend
│ └── (same as TypeScript)
└── data/
```
---
## Service Abstraction Layer
The `services/` directory is the **critical architectural component** for testability. Each file wraps one Azure service behind interface. Handlers receive services via DI — never import SDKs directly.
> Full service abstraction architecture: see [service-abstraction.md](.github/agents/shared-references/service-abstraction.md).
---
## Function Organization
### One Function Per File (Required)
```
services/functions/src/functions/
├── getItems.ts ← HTTP GET /api/items
├── createItem.ts ← HTTP POST /api/items
├── getItemById.ts ← HTTP GET /api/items/{id}
├── updateItem.ts ← HTTP PUT /api/items/{id}
├── deleteItem.ts ← HTTP DELETE /api/items/{id}
├── health.ts ← HTTP GET /api/health
└── openapi.ts ← HTTP GET /api/openapi.json
```
Each function receives deps via service registry:
```typescript
// Example: clean handler with injected services
import { app } from "@azure/functions";
import { getServices } from "../services/registry";
app.http("getItems", {
methods: ["GET"],
authLevel: "anonymous",
route: "items",
handler: async (request, context) => {
const { database } = getServices();
const items = await database.findAll("items");
return { jsonBody: { items } };
}
});
```
### Shared Handler Utilities (Required — DRY Enforcement)
When same helper needed in 3+ handlers, extract to `services/functions/src/utils/` — do NOT duplicate inline.
**Common examples:**
```typescript
// services/functions/src/utils/toPublicUser.ts
import type { User, PublicUser } from '../../../shared/types/entities.js';
export function toPublicUser(user: User): PublicUser {
return {
id: user.id,
email: user.email,
displayName: user.displayName,
coupleId: user.coupleId,
createdAt: user.createdAt,
};
}
```
```typescript
// Usage in handler — import, don't redefine
import { toPublicUser } from '../utils/toPublicUser.js';
```
**Detection**: After Step 6, grep for repeated helper names across handlers. If 3+ files, extract.
**Enforcement**: Step 12 MUST check for duplicated helpers and extract before finalization.
---
## Frontend Dev Server Configuration (proxy + preview compatibility)
When a frontend is included, its dev server must (a) proxy `/api` to the Functions host **and** (b) be embeddable in the scaffold's **Approve UI** preview, which starts this dev server and renders it inside a **VS Code webview iframe** (forwarding the port in remote / Codespaces / Dev Container / SSH sessions). Miss (b) and the preview hangs on "Starting…" or renders blank even though the app opens fine in a normal browser — the user is then **stuck at the approval gate**.
### Vite (React, Vue, Svelte)
```typescript
// vite.config.ts
export default defineConfig({
server: {
// (b) Preview compatibility — let the webview iframe / forwarded host load the app.
host: true, // bind 0.0.0.0 so the webview / port-forwarder can reach it
allowedHosts: true, // dev-only: don't 403-block the webview / forwarded origin
strictPort: false, // let the preview bind a free port if the default is taken
// (a) Proxy /api to the Functions host.
proxy: {
'/api': {
target: 'http://localhost:7071',
changeOrigin: true
}
}
}
});
```
### Angular
```json
// proxy.conf.json
{
"/api": {
"target": "http://localhost:7071",
"secure": false
}
}
```
> For Angular, also serve with host binding + host-check disabled so the preview iframe can load it (`ng serve --host 0.0.0.0 --disable-host-check`, or the equivalent `serve` options in `angular.json`). For Next.js, bind all interfaces (`next dev -H 0.0.0.0`). The goal is identical to Vite's `host: true` + `allowedHosts: true`.
> **Do NOT frame-bust the dev server.** Never send `X-Frame-Options` from the dev server and never add a `<meta http-equiv="Content-Security-Policy" content="… frame-ancestors …">` to `index.html`. Those let a normal browser tab load the app but block it from being embedded in the preview's webview iframe — the exact "works in my browser, blank in the preview" trap.
---
## Monorepo Package Management
### npm Workspaces (TypeScript)
```json
{
"private": true,
"workspaces": ["services/functions", "services/web", "services/shared"],
"scripts": {
"test": "npm test --workspaces",
"test:functions": "cd services/functions && npm test",
"test:web": "cd services/web && npm test",
"build": "npm run build --workspaces"
}
}
```
### TypeScript Cross-Workspace Import Configuration
When Functions imports from `../shared/`, `tsconfig.json` must set `rootDir` to reach outside workspace:
```jsonc
// services/functions/tsconfig.json
{
"compilerOptions": {
"rootDir": "..", // ← Parent of functions dir (i.e., services/)
"outDir": "dist",
// ... other options
},
"include": ["src/**/*.ts", "../shared/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}
```
> ⛔ **Import the shared code by relative path (`../shared/...`) or by the shared package's `name` — NEVER through a tsconfig `paths` alias.**
>
> `tsc` resolves `paths` at compile time and emits the module specifier **unchanged**. `paths` has no runtime counterpart (no `tsconfig-paths`, no bundler, no `imports` map in these projects), so the alias survives into `dist/` and Node cannot resolve it. The build is green, `tsc --noEmit` is green, and `func start` dies on load.
>
> ```jsonc
> // ❌ compiles, fails at runtime — tsc emits require('@shared/schemas/index') unchanged
> // → Worker was unable to load entry point: Cannot find module '@shared/schemas/index'
> { "compilerOptions": { "paths": { "@shared/*": ["shared/src/*"] } } }
> ```
>
> ```ts
> // ❌ import { createTaskSchema } from '@shared/schemas/index';
> // ✅ relative form — count the levels from the importing FILE, not from the workspace root.
> // services/functions/src/functions/createTask.ts -> services/shared/schemas/index
> import { createTaskSchema } from '../../../shared/schemas/index';
> // ✅ or the workspace package's own name, exactly as written in services/shared/package.json
> import { createTaskSchema } from '@<project>/shared/schemas';
> ```
>
> These are the only two supported mechanisms. Picking both leaves two competing paths to the same code, and the compile-time-only one wins silently.
> ⚠️ **Build output nesting — `main` MUST match actual dist/ output (Rule 14)**
>
> When `rootDir` is parent dir, `tsc` mirrors full structure under `dist/`. `main` in `package.json` MUST be computed from actual output — never hardcoded.
>
> | `rootDir` value | `services/functions/src/functions/register.ts` compiles to | Correct `main` field |
> |-----------------|-------------------------------------------------------|---------------------|
> | `"."` | `dist/src/functions/register.js` | `"dist/src/functions/*.js"` |
> | `".."` (= `services/`) | `dist/functions/src/functions/register.js` | `"dist/functions/src/functions/*.js"` |
> | `"../.."` (= project root) | `dist/services/functions/src/functions/register.js` | `"dist/services/functions/src/functions/*.js"` |
>
> **Verification (MANDATORY after every `tsc` build):**
> 1. Run `tsc` in functions workspace
> 2. List `dist/` — find compiled handler `.js` files
> 3. Construct matching glob
> 4. Set `main` to that glob
> 5. `func start` — verify functions register. "Found zero files" = wrong `main`.
>
> **#1 cause of "tests pass but app won't start".** Tests use vitest/ts-node (transpile on fly, never read `main`). Only `func start` uses `main` to discover handlers.
### Python (Poetry)
```toml
# pyproject.toml at project root
[tool.poetry]
packages = [
{ include = "services", from = "services/functions" },
{ include = "shared", from = "services" },
]
```
### .NET (Solution)
```xml
<!-- ProjectName.sln references -->
<!-- services/Functions/Functions.csproj -->
<!-- services/Functions.Tests/Functions.Tests.csproj -->
<!-- services/Shared/Shared.csproj -->
```
---
## .gitignore Additions
> ⛔ **Ignore `.env` and `local.settings.json` before creating them.** They hold real credential
> values. A secret that reaches a commit is compromised the moment it is pushed, and deleting it in
> a later commit does **not** undo that — the value stays in history and in every clone and fork
> that already has it. Recovery means rotating the credential, not editing a file. This applies to
> private repositories exactly as it does to public ones: no secret belongs in any commit, ever.
```gitignore
# Environment
.env
local.settings.json
# Data volumes
data/
# Build output
dist/
bin/
obj/
.vite/
# Runtime
node_modules/
__pycache__/
.python_packages/
# Test output
coverage/
.pytest_cache/
TestResults/
# IDE
.vs/
```
---
## Port Allocation Convention
| Service | Port | Notes |
|---------|------|-------|
| Azure Functions host | 7071 | Default `func start` port |
| Frontend dev server (Vite) | 5173 | Default Vite port |
| Frontend dev server (Angular) | 4200 | Default Angular port |
| Azurite Blob | 10000 | |
| Azurite Queue | 10001 | |
| Azurite Table | 10002 | |
| PostgreSQL | 5432 | |
| CosmosDB Emulator | 8081 | |
| Redis | 6379 | |
| Azure SQL Edge | 1433 | |
@@ -0,0 +1,319 @@
<!-- azure-cor-disclaimer -->
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
# Database Integrity Patterns
> Schema constraints, transactions, and indexes — database is last line of defense against data corruption.
---
## Core Principle
**Application-level checks are necessary but insufficient.** Database schema must enforce correctness even under concurrent access. Race conditions, partial failures, and unexpected input can bypass application logic — database constraints must catch what code misses.
---
## Rule: Constraints Are Mandatory in Migrations
Every migration MUST include appropriate constraints. Do not rely solely on application-level validation.
### UNIQUE Constraints
Any field that must be unique across the table (email, username, slug, invite token) MUST have database-level UNIQUE constraint.
```sql
-- Application-level check alone is NOT sufficient (race condition under concurrent requests)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE, -- ← REQUIRED: prevents duplicate registration race condition
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
couple_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
**Why application-level checks fail:**
```
Request A: SELECT WHERE email = 'alice@test.com' → not found → INSERT
Request B: SELECT WHERE email = 'alice@test.com' → not found → INSERT ← Both succeed!
```
With UNIQUE constraint, second INSERT fails with constraint violation, which error handler maps to 409 Conflict.
### Foreign Key Constraints
Any field referencing another table MUST have FK constraint with explicit ON DELETE behavior.
```sql
CREATE TABLE photos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
uploaded_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blob_url TEXT NOT NULL,
thumbnail_url TEXT,
caption TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
| ON DELETE Behavior | When to Use |
|-------------------|-------------|
| `CASCADE` | Child records deleted when parent deleted (photos when couple deleted) |
| `SET NULL` | Child remains but loses reference (user.couple_id when couple dissolved) |
| `RESTRICT` | Prevent parent deletion if children exist (user can't be deleted if they own photos) |
### CHECK Constraints
Business rules expressible as column constraints should be enforced at database level:
```sql
ALTER TABLE pairing_invites
ADD CONSTRAINT valid_status CHECK (status IN ('pending', 'accepted', 'rejected'));
ALTER TABLE users
ADD CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$');
```
### Partial UNIQUE Constraints
When UNIQUE constraint should only apply to rows matching a condition (e.g., prevent duplicate *pending* invites but allow multiple *rejected* ones), use PostgreSQL **partial unique index**:
```sql
-- Prevent duplicate pending invites from the same user to the same email
CREATE UNIQUE INDEX idx_unique_pending_invite
ON invites(from_user_id, to_email) WHERE status = 'pending';
```
> ⚠️ **Application-level checks are NOT sufficient** for partial uniqueness — concurrent requests can both pass `findOne` check and both INSERT. Database constraint is defense-in-depth layer preventing this race condition. Always include partial UNIQUE indexes in migrations when plan specifies them.
### NOT NULL
Default to `NOT NULL`. Use `NULL` only when absence of value is a meaningful business state:
| ✅ Nullable (meaningful absence) | ❌ Should be NOT NULL |
|----------------------------------|----------------------|
| `user.couple_id` (unpaired user) | `user.email` |
| `photo.thumbnail_url` (not yet generated) | `photo.blob_url` |
---
## Rule: Transactions for Multi-Table Writes
Any operation that writes to 2+ tables MUST use database transaction. Without transaction, failure mid-sequence leaves database in inconsistent state.
### IDatabaseService Transaction Method
Add to database service interface:
#### TypeScript
```typescript
// services/interfaces/IDatabaseService.ts
export interface IDatabaseService {
findAll<T>(collection: string, options?: QueryOptions): Promise<T[]>;
findById<T>(collection: string, id: string): Promise<T | null>;
findOne<T>(collection: string, filter: Record<string, unknown>): Promise<T | null>;
create<T>(collection: string, data: T): Promise<T>;
update<T>(collection: string, id: string, data: Partial<T>): Promise<T | null>;
delete(collection: string, id: string): Promise<boolean>;
count(collection: string, filter?: Record<string, unknown>): Promise<number>;
healthCheck(): Promise<boolean>;
// Execute multiple operations atomically — all succeed or all rollback
transaction<T>(fn: (trx: IDatabaseService) => Promise<T>): Promise<T>;
}
```
#### Python
```python
# services/interfaces/database_service.py
from typing import Protocol
class IDatabaseService(Protocol):
# ... existing methods ...
async def transaction(self, fn) -> any:
"""Execute fn within a database transaction. fn receives a transactional
IDatabaseService instance. If fn throws, all changes are rolled back."""
...
```
#### C#
```csharp
// Services/Interfaces/IDatabaseService.cs
public interface IDatabaseService
{
// ... existing methods ...
Task<T> TransactionAsync<T>(Func<IDatabaseService, Task<T>> fn);
}
```
### Concrete Implementation (PostgreSQL)
```typescript
// services/database.ts
async transaction<T>(fn: (trx: IDatabaseService) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// Create a transaction-scoped service that uses this client instead of the pool
const trxService = new TransactionDatabaseService(client);
const result = await fn(trxService);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
```
### Mock Implementation
```typescript
// tests/mocks/mockDatabase.ts
async transaction<T>(fn: (trx: IDatabaseService) => Promise<T>): Promise<T> {
// Mock transactions execute callback directly against in-memory state.
// For most tests this is sufficient — transaction boundary tested
// via integration tests with real database.
return fn(this);
}
```
### Usage in Handlers
```typescript
// BAD — 4 sequential writes with no atomicity
const couple = await database.create('couples', { ... });
await database.update('users', user1Id, { coupleId });
await database.update('users', user2Id, { coupleId });
await database.update('pairing_invites', inviteId, { status: 'accepted' });
// GOOD — all-or-nothing transaction
const couple = await database.transaction(async (trx) => {
const couple = await trx.create('couples', {
id: uuid(),
user1Id: invite.fromUserId,
user2Id: userId,
createdAt: new Date().toISOString(),
});
await trx.update('users', invite.fromUserId, { coupleId: couple.id });
await trx.update('users', userId, { coupleId: couple.id });
await trx.update('pairing_invites', inviteId, { status: 'accepted' });
return couple;
});
```
---
## Rule: Indexes on Frequently Queried Columns
Migrations should include indexes for columns used in:
- `WHERE` clauses (filter queries)
- `JOIN` conditions
- `ORDER BY` clauses
- Foreign keys
```sql
-- Foreign keys used in WHERE/JOIN
CREATE INDEX idx_photos_couple_id ON photos(couple_id);
CREATE INDEX idx_invites_to_email ON pairing_invites(to_email);
CREATE INDEX idx_invites_from_user ON pairing_invites(from_user_id);
-- Composite indexes for common query patterns
CREATE INDEX idx_invites_lookup ON pairing_invites(to_email, status);
```
---
## Rule: Handle Constraint Violations in Error Handler
When database rejects operation due to constraint violation, map to appropriate HTTP error:
### TypeScript
```typescript
// In errorHandler.ts — add constraint violation handling
if (error instanceof Error && error.message?.includes('duplicate key')) {
return {
status: 409,
jsonBody: {
error: {
code: 'CONFLICT',
message: 'A record with this value already exists',
details: null,
},
},
};
}
if (error instanceof Error && error.message?.includes('violates foreign key')) {
return {
status: 400,
jsonBody: {
error: {
code: 'BAD_REQUEST',
message: 'Referenced record does not exist',
details: null,
},
},
};
}
```
---
## Planning Checkpoint
During Phase 1 planning, project plan MUST include **Database Constraints** section:
```markdown
## Database Constraints
| Table | Constraint Type | Column(s) | Detail |
|-------|----------------|-----------|--------|
| users | UNIQUE | email | Prevent duplicate registration |
| users | FK | couple_id → couples.id | ON DELETE SET NULL |
| photos | FK | couple_id → couples.id | ON DELETE CASCADE |
| photos | FK | uploaded_by_user_id → users.id | ON DELETE CASCADE |
| photos | INDEX | couple_id | Filter photos by couple |
| pairing_invites | CHECK | status | IN ('pending', 'accepted', 'rejected') |
| pairing_invites | FK | from_user_id → users.id | ON DELETE CASCADE |
| pairing_invites | INDEX | to_email, status | Invite lookup |
```
---
## Testing Database Integrity
```typescript
describe('database constraints', () => {
it('should reject duplicate email registration', async () => {
// First registration succeeds
await database.create('users', { id: uuid(), email: 'alice@test.com', ... });
// Second registration with same email should fail
await expect(
database.create('users', { id: uuid(), email: 'alice@test.com', ... })
).rejects.toThrow();
});
it('should cascade delete photos when couple is deleted', async () => {
const photos = await database.findAll('photos', { filter: { coupleId } });
expect(photos).toHaveLength(3);
await database.delete('couples', coupleId);
const remaining = await database.findAll('photos', { filter: { coupleId } });
expect(remaining).toHaveLength(0);
});
});
```
@@ -0,0 +1,479 @@
<!-- azure-cor-disclaimer -->
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
# Error Handling
> Standardized error responses, error types, and middleware for consistent error handling across all routes.
---
## Core Principle
**Every route returns errors in a consistent shape.** Clients rely on single error format for all endpoints. Error paths tested as thoroughly as happy paths.
---
## Standardized Error Response Shape
All error responses follow this shape:
```json
{
"error": {
"code": "NOT_FOUND",
"message": "Item with ID 'xyz' was not found",
"details": null
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `error.code` | `string` | Machine-readable error code (e.g., `VALIDATION_ERROR`, `NOT_FOUND`) |
| `error.message` | `string` | Human-readable error description |
| `error.details` | `any?` | Optional extra details (validation errors, field-level issues). **Omitted in production** for security-sensitive errors. |
---
## Error Code → HTTP Status Mapping
| Error Code | HTTP Status | When |
|------------|-------------|------|
| `VALIDATION_ERROR` | 422 | Request body fails validation (Zod/Pydantic/FluentValidation) |
| `BAD_REQUEST` | 400 | Malformed request (missing params, wrong content type) |
| `NOT_FOUND` | 404 | Resource doesn't exist |
| `CONFLICT` | 409 | Duplicate resource or state conflict |
| `UNAUTHORIZED` | 401 | Missing or invalid auth token |
| `FORBIDDEN` | 403 | Valid auth but insufficient permissions |
| `INTERNAL_ERROR` | 500 | Unhandled exception or service failure |
---
## Error Code Type Safety
Error codes MUST be defined as typed union in shared types package, not arbitrary strings. Enables frontend consumers to switch on error codes with exhaustiveness checking.
### TypeScript
```typescript
// shared/types/errors.ts
export type ErrorCode =
| 'VALIDATION_ERROR'
| 'BAD_REQUEST'
| 'NOT_FOUND'
| 'CONFLICT'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'INTERNAL_ERROR';
export interface ErrorResponse {
error: {
code: ErrorCode; // ← typed union, not string
message: string;
details: Record<string, unknown> | null;
};
}
```
### Python
```python
# shared/types.py
from enum import Enum
class ErrorCode(str, Enum):
VALIDATION_ERROR = "VALIDATION_ERROR"
BAD_REQUEST = "BAD_REQUEST"
NOT_FOUND = "NOT_FOUND"
CONFLICT = "CONFLICT"
UNAUTHORIZED = "UNAUTHORIZED"
FORBIDDEN = "FORBIDDEN"
INTERNAL_ERROR = "INTERNAL_ERROR"
```
### C#
```csharp
// Shared/ErrorCode.cs
public static class ErrorCodes
{
public const string ValidationError = "VALIDATION_ERROR";
public const string BadRequest = "BAD_REQUEST";
public const string NotFound = "NOT_FOUND";
public const string Conflict = "CONFLICT";
public const string Unauthorized = "UNAUTHORIZED";
public const string Forbidden = "FORBIDDEN";
public const string InternalError = "INTERNAL_ERROR";
}
```
### Frontend Usage
With typed error codes, frontend can handle specific error types:
```typescript
import type { ErrorCode } from 'app-shared';
function handleApiError(code: ErrorCode, message: string) {
switch (code) {
case 'UNAUTHORIZED':
// Redirect to login
break;
case 'CONFLICT':
// Show "already exists" message
break;
case 'VALIDATION_ERROR':
// Show field-level errors
break;
default:
// Show generic error
break;
}
}
```
---
## TypeScript Implementation
### Error Types
```typescript
// errors/AppError.ts
export class AppError extends Error {
public readonly statusCode: number;
public readonly code: string;
public readonly details?: unknown;
constructor(statusCode: number, code: string, message: string, details?: unknown) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
this.name = this.constructor.name;
}
}
```
```typescript
// errors/errorTypes.ts
import { AppError } from './AppError';
export class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(404, 'NOT_FOUND', `${resource} with ID '${id}' was not found`);
}
}
export class ValidationError extends AppError {
constructor(message: string, details?: unknown) {
super(422, 'VALIDATION_ERROR', message, details);
}
}
export class BadRequestError extends AppError {
constructor(message: string) {
super(400, 'BAD_REQUEST', message);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(409, 'CONFLICT', message);
}
}
export class UnauthorizedError extends AppError {
constructor(message: string = 'Authentication required') {
super(401, 'UNAUTHORIZED', message);
}
}
export class ForbiddenError extends AppError {
constructor(message: string = 'Insufficient permissions') {
super(403, 'FORBIDDEN', message);
}
}
```
### Error Handler
```typescript
// errors/errorHandler.ts
import { HttpResponseInit, InvocationContext } from '@azure/functions';
import { AppError } from './AppError';
import { ZodError } from 'zod';
import { getLogger } from '../logger';
const logger = getLogger();
export function handleError(error: unknown, context: InvocationContext): HttpResponseInit {
// Known application errors
if (error instanceof AppError) {
logger.warn({ err: error, code: error.code }, error.message);
return {
status: error.statusCode,
jsonBody: {
error: {
code: error.code,
message: error.message,
details: error.details ?? null,
},
},
};
}
// Zod validation errors → map to ValidationError shape
if (error instanceof ZodError) {
const details = error.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
}));
logger.warn({ err: error, details }, 'Validation failed');
return {
status: 422,
jsonBody: {
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
details,
},
},
};
}
// Unknown errors → 500
const err = error instanceof Error ? error : new Error(String(error));
logger.error({ err }, 'Unhandled error');
return {
status: 500,
jsonBody: {
error: {
code: 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production'
? 'An internal error occurred'
: err.message,
details: null,
},
},
};
}
```
### Request Validation Middleware
```typescript
// middleware/validateRequest.ts
import { HttpRequest } from '@azure/functions';
import { ZodSchema, ZodError } from 'zod';
import { ValidationError } from '../errors/errorTypes';
export async function validateBody<T>(request: HttpRequest, schema: ZodSchema<T>): Promise<T> {
let body: unknown;
try {
body = await request.json();
} catch {
throw new ValidationError('Request body must be valid JSON');
}
const result = schema.safeParse(body);
if (!result.success) {
const details = result.error.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
}));
throw new ValidationError('Request validation failed', details);
}
return result.data;
}
export function validateParams(params: Record<string, string>, required: string[]): void {
const missing = required.filter(key => !params[key]);
if (missing.length > 0) {
throw new ValidationError(`Missing required parameters: ${missing.join(', ')}`);
}
}
```
### Usage in Function Handlers
```typescript
// functions/createItem.ts
import { app } from '@azure/functions';
import { getServices } from '../services/registry';
import { handleError } from '../errors/errorHandler';
import { validateBody } from '../middleware/validateRequest';
import { createItemSchema } from '../../shared/schemas/validation';
import { v4 as uuid } from 'uuid';
app.http('createItem', {
methods: ['POST'],
authLevel: 'anonymous',
route: 'items',
handler: async (request, context) => {
try {
const body = await validateBody(request, createItemSchema);
const { database } = getServices();
const item = {
id: uuid(),
...body,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const created = await database.create('items', item);
return { status: 201, jsonBody: { item: created } };
} catch (error) {
return handleError(error, context);
}
}
});
```
---
For Python error handling, see [runtimes/python.md](.github/agents/shared-references/runtimes/python.md). For C#, see [runtimes/dotnet.md](.github/agents/shared-references/runtimes/dotnet.md).
---
## Testing Error Handling
### TypeScript Tests
```typescript
// tests/errors/errorHandler.test.ts
import { describe, it, expect, vi } from 'vitest';
import { handleError } from '../../src/errors/errorHandler';
import { NotFoundError, ValidationError, BadRequestError } from '../../src/errors/errorTypes';
import { InvocationContext } from '@azure/functions';
const mockContext = { log: vi.fn() } as unknown as InvocationContext;
describe('errorHandler', () => {
it('should return 404 for NotFoundError', () => {
const error = new NotFoundError('Item', 'abc-123');
const response = handleError(error, mockContext);
expect(response.status).toBe(404);
expect(response.jsonBody).toEqual({
error: {
code: 'NOT_FOUND',
message: "Item with ID 'abc-123' was not found",
details: null,
},
});
});
it('should return 422 for ValidationError', () => {
const details = [{ field: 'name', message: 'Required' }];
const error = new ValidationError('Validation failed', details);
const response = handleError(error, mockContext);
expect(response.status).toBe(422);
expect(response.jsonBody.error.code).toBe('VALIDATION_ERROR');
expect(response.jsonBody.error.details).toEqual(details);
});
it('should return 400 for BadRequestError', () => {
const error = new BadRequestError('Missing content type');
const response = handleError(error, mockContext);
expect(response.status).toBe(400);
expect(response.jsonBody.error.code).toBe('BAD_REQUEST');
});
it('should return 500 for unknown errors', () => {
const error = new Error('Something broke');
const response = handleError(error, mockContext);
expect(response.status).toBe(500);
expect(response.jsonBody.error.code).toBe('INTERNAL_ERROR');
});
it('should return consistent error shape for all error types', () => {
const errors = [
new NotFoundError('Item', '1'),
new ValidationError('Bad input'),
new BadRequestError('Bad request'),
new Error('Unknown'),
];
for (const error of errors) {
const response = handleError(error, mockContext);
expect(response.jsonBody).toHaveProperty('error');
expect(response.jsonBody.error).toHaveProperty('code');
expect(response.jsonBody.error).toHaveProperty('message');
expect(response.jsonBody.error).toHaveProperty('details');
}
});
});
```
### Validation Schema Tests
```typescript
// tests/validation/itemSchema.test.ts
import { describe, it, expect } from 'vitest';
import { createItemSchema } from '../../services/shared/schemas/validation';
describe('createItemSchema', () => {
it('should pass with valid input', () => {
const result = createItemSchema.safeParse({
name: 'Widget',
description: 'A nice widget',
price: 29.99,
category: 'widgets',
});
expect(result.success).toBe(true);
});
it('should fail when name is empty', () => {
const result = createItemSchema.safeParse({
name: '',
description: 'A widget',
price: 29.99,
category: 'widgets',
});
expect(result.success).toBe(false);
});
it('should fail when price is negative', () => {
const result = createItemSchema.safeParse({
name: 'Widget',
description: 'A widget',
price: -5,
category: 'widgets',
});
expect(result.success).toBe(false);
});
it('should fail when required fields are missing', () => {
const result = createItemSchema.safeParse({
description: 'Just a description',
});
expect(result.success).toBe(false);
});
it('should fail when name is not a string', () => {
const result = createItemSchema.safeParse({
name: 123,
description: 'A widget',
price: 29.99,
category: 'widgets',
});
expect(result.success).toBe(false);
});
});
```
---
## Validation Library Quick Reference
| Runtime | Library | Schema Example |
|---------|---------|---------------|
| TypeScript | **Zod** | `z.object({ name: z.string().min(1), price: z.number().positive() })` |
| Python | **Pydantic** | `class CreateItem(BaseModel): name: str = Field(min_length=1); price: float = Field(gt=0)` |
| C# | **FluentValidation** | `RuleFor(x => x.Name).NotEmpty(); RuleFor(x => x.Price).GreaterThan(0);` |
@@ -0,0 +1,304 @@
<!-- azure-cor-disclaimer -->
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
# Service Abstraction — Full Code Examples
> Complete implementation examples referenced by [service-abstraction.md](.github/agents/shared-references/service-abstraction.md). Read this file ONLY during Step 3 (Service Abstraction Layer).
---
## Concrete Implementation (PostgreSQL)
> Includes camelCase↔snake_case key conversion. TypeScript entities use camelCase but PostgreSQL columns are snake_case. Conversion handled transparently — function handlers never deal with snake_case.
```typescript
// services/database.ts
import { Pool } from 'pg';
import { IDatabaseService, QueryOptions } from './interfaces/IDatabaseService';
import { loadConfig } from './config';
// --- camelCase ↔ snake_case conversion utilities ---
function toSnake(str: string): string {
return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
}
function toCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
function keysToSnake(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
result[toSnake(key)] = value;
}
return result;
}
function keysToCamel<T>(obj: Record<string, unknown>): T {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
result[toCamel(key)] = value;
}
return result as T;
}
function rowsToCamel<T>(rows: Record<string, unknown>[]): T[] {
return rows.map(row => keysToCamel<T>(row));
}
// --- Collection name → SQL table name mapping ---
function collectionToTable(collection: string): string {
const map: Record<string, string> = {
// Add all entity → table mappings here
// e.g., user: 'users', couple: 'couples', photo: 'photos'
};
return map[collection] ?? `${collection}s`;
}
// --- Database service implementation ---
export class PostgresDatabaseService implements IDatabaseService {
private pool: Pool;
constructor(connectionString?: string) {
const config = loadConfig();
this.pool = new Pool({
connectionString: connectionString || config.databaseUrl,
max: 20,
idleTimeoutMillis: 30000,
});
}
async findAll<T>(collection: string, options?: QueryOptions): Promise<T[]> {
const table = collectionToTable(collection);
const limit = options?.limit || 100;
const offset = options?.offset || 0;
const orderBy = toSnake(options?.orderBy || 'createdAt');
const direction = options?.orderDirection || 'desc';
const result = await this.pool.query(
`SELECT * FROM ${table} ORDER BY ${orderBy} ${direction} LIMIT $1 OFFSET $2`,
[limit, offset]
);
return rowsToCamel<T>(result.rows);
}
async findById<T>(collection: string, id: string): Promise<T | null> {
const table = collectionToTable(collection);
const result = await this.pool.query(`SELECT * FROM ${table} WHERE id = $1`, [id]);
return result.rows[0] ? keysToCamel<T>(result.rows[0]) : null;
}
async findOne<T>(collection: string, filter: Record<string, unknown>): Promise<T | null> {
const table = collectionToTable(collection);
const snakeFilter = keysToSnake(filter);
const entries = Object.entries(snakeFilter);
const conditions = entries.map(([key], i) => `${key} = $${i + 1}`);
const values = entries.map(([, val]) => val);
const result = await this.pool.query(
`SELECT * FROM ${table} WHERE ${conditions.join(' AND ')} LIMIT 1`, values
);
return result.rows[0] ? keysToCamel<T>(result.rows[0]) : null;
}
async create<T>(collection: string, data: T): Promise<T> {
const table = collectionToTable(collection);
const record = data as Record<string, unknown>;
const { createdAt: _ca, updatedAt: _ua, ...cleanData } = record;
const snakeData = keysToSnake(cleanData);
const keys = Object.keys(snakeData);
const values = Object.values(snakeData);
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
const result = await this.pool.query(
`INSERT INTO ${table} (${keys.join(', ')}) VALUES (${placeholders}) RETURNING *`, values
);
return keysToCamel<T>(result.rows[0]);
}
async update<T>(collection: string, id: string, data: Partial<T>): Promise<T | null> {
const table = collectionToTable(collection);
const record = data as Record<string, unknown>;
const { id: _id, createdAt: _ca, updatedAt: _ua, ...cleanData } = record;
const snakeData = keysToSnake(cleanData);
const entries = Object.entries(snakeData);
const sets = entries.map(([key], i) => `${key} = $${i + 1}`).join(', ');
const values = [...entries.map(([, val]) => val), id];
const result = await this.pool.query(
`UPDATE ${table} SET ${sets}, updated_at = NOW() WHERE id = $${values.length} RETURNING *`, values
);
return result.rows[0] ? keysToCamel<T>(result.rows[0]) : null;
}
async delete(collection: string, id: string): Promise<boolean> {
const table = collectionToTable(collection);
const result = await this.pool.query(`DELETE FROM ${table} WHERE id = $1`, [id]);
return (result.rowCount ?? 0) > 0;
}
async count(collection: string, filter?: Record<string, unknown>): Promise<number> {
const table = collectionToTable(collection);
// ... filter handling similar to findAll
const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM ${table}`);
return result.rows[0].count;
}
async healthCheck(): Promise<boolean> {
try { await this.pool.query('SELECT 1'); return true; }
catch { return false; }
}
async transaction<T>(fn: (trx: IDatabaseService) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// Create a transaction-scoped service using this client
const trxService = createTransactionService(client);
const result = await fn(trxService);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
}
```
---
## Mock Implementation (For Tests)
```typescript
// tests/mocks/mockDatabase.ts
import { IDatabaseService, QueryOptions } from '../../src/services/interfaces/IDatabaseService';
export class MockDatabaseService implements IDatabaseService {
private stores: Map<string, Map<string, unknown>> = new Map();
constructor(initialData?: Record<string, unknown[]>) {
if (initialData) {
for (const [collection, items] of Object.entries(initialData)) {
const store = new Map<string, unknown>();
items.forEach((item: any) => store.set(item.id, item));
this.stores.set(collection, store);
}
}
}
private getStore(collection: string): Map<string, unknown> {
if (!this.stores.has(collection)) this.stores.set(collection, new Map());
return this.stores.get(collection)!;
}
async findAll<T>(collection: string, options?: QueryOptions): Promise<T[]> {
const store = this.getStore(collection);
let items = Array.from(store.values()) as T[];
if (options?.limit) {
const offset = options.offset || 0;
items = items.slice(offset, offset + options.limit);
}
return items;
}
async findById<T>(collection: string, id: string): Promise<T | null> {
return (this.getStore(collection).get(id) as T) || null;
}
async findOne<T>(collection: string, filter: Record<string, unknown>): Promise<T | null> {
for (const item of this.getStore(collection).values()) {
const record = item as Record<string, unknown>;
if (Object.entries(filter).every(([k, v]) => record[k] === v)) return item as T;
}
return null;
}
async create<T>(collection: string, data: T): Promise<T> {
const item = data as Record<string, unknown>;
this.getStore(collection).set(item.id as string, item);
return data;
}
async update<T>(collection: string, id: string, data: Partial<T>): Promise<T | null> {
const store = this.getStore(collection);
const existing = store.get(id);
if (!existing) return null;
const updated = { ...existing, ...data, updatedAt: new Date().toISOString() };
store.set(id, updated);
return updated as T;
}
async delete(collection: string, id: string): Promise<boolean> {
return this.getStore(collection).delete(id);
}
async count(collection: string, filter?: Record<string, unknown>): Promise<number> {
if (!filter) return this.getStore(collection).size;
return (await this.findAll(collection)).length;
}
async healthCheck(): Promise<boolean> { return true; }
async transaction<T>(fn: (trx: IDatabaseService) => Promise<T>): Promise<T> {
return fn(this); // Mock: execute directly, no rollback
}
}
```
---
## Service Registry (DI)
> **Critical**: Auto-initializes at runtime. Enhancement services wrapped in try/catch.
```typescript
// services/registry.ts
import { IDatabaseService } from './interfaces/IDatabaseService';
import { IStorageService } from './interfaces/IStorageService';
// ... other interfaces
import { PostgresDatabaseService } from './database';
import { BlobStorageService } from './storage';
// ... other concrete implementations
export interface ServiceRegistry {
database: IDatabaseService;
storage: IStorageService;
// ... other services
}
let services: ServiceRegistry | null = null;
export function registerServices(registry: ServiceRegistry): void {
services = registry;
}
export function getServices(): ServiceRegistry {
if (!services) initializeServices();
return services!;
}
export function clearServices(): void {
services = null;
}
function initializeServices(): void {
// Essential services — let them throw
const database = new PostgresDatabaseService();
const storage = new BlobStorageService();
// Enhancement services — wrapped in try/catch with no-op fallback
let aiCaption: IAICaptionService;
try {
aiCaption = new AzureAICaptionService();
} catch (err) {
logger.warn({ err }, 'AI caption service unavailable, using no-op fallback');
aiCaption = { generateCaption: async () => 'A special moment 📸', healthCheck: async () => false };
}
services = { database, storage, aiCaption };
}
```
@@ -0,0 +1,411 @@
<!-- azure-cor-disclaimer -->
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
# Frontend Architecture Patterns
> Frontend has same quality bar as backend: typed, tested, structured.
> **Companion contract**: For the **visual** quality bar — how to render Section 6's layout tokens with real library primitives, how to theme the app from the plan's palette, and the four-state coverage gate — read [frontend-quality-bar.md](.github/agents/shared-references/frontend-quality-bar.md). This file covers structural patterns (types, hooks, services, error handling); the quality bar covers what each region must actually render.
---
## Core Principle
**Frontend is not second-class.** Consumes shared types package, has own test gate, follows consistent patterns for data fetching, error handling, component structure. **Layout tokens from the plan are intent, not literal `<div>`s** — see [frontend-quality-bar.md](.github/agents/shared-references/frontend-quality-bar.md).
---
## Rule: Consume Shared Types — No `any`
Shared package exists so frontend doesn't reinvent types. Every entity, request, response MUST use shared type.
```typescript
// ❌ BAD — defeats the purpose of the shared types package
const [user, setUser] = useState<any>(null);
const [photos, setPhotos] = useState<any[]>([]);
const [error, setError] = useState<any>(null);
// ✅ GOOD — imports from shared package
import type { PublicUser, Photo } from 'scrapbook-shared';
const [user, setUser] = useState<PublicUser | null>(null);
const [photos, setPhotos] = useState<Photo[]>([]);
const [error, setError] = useState<string | null>(null);
```
**Enforcement**: If shared package defines a type for an entity or API response, frontend MUST import and use it. No inline `any` or ad-hoc interfaces duplicating shared definitions.
---
## Rule: API Client Must Be Fully Typed
API client module MUST use shared request/response types for every endpoint. Client is contract boundary between frontend and backend.
```typescript
// api/client.ts
import type {
AuthResponse,
MeResponse,
LoginRequest,
RegisterRequest,
ListPhotosResponse,
PhotoResponse,
ErrorResponse,
} from 'app-shared';
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {
...((options.headers as Record<string, string>) || {}),
};
if (token) headers['Authorization'] = `Bearer ${token}`;
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`/api${path}`, { ...options, headers });
if (!res.ok) {
const error: ErrorResponse = await res.json();
throw new ApiError(res.status, error.error.code, error.error.message);
}
return res.json() as Promise<T>;
}
// Every endpoint is fully typed — no `any`
export const api = {
login: (data: LoginRequest) =>
request<AuthResponse>('POST', '/auth/login', { body: JSON.stringify(data) }),
register: (data: RegisterRequest) =>
request<AuthResponse>('POST', '/auth/register', { body: JSON.stringify(data) }),
getMe: () =>
request<MeResponse>('GET', '/auth/me'),
listPhotos: (limit = 20, offset = 0) =>
request<ListPhotosResponse>('GET', `/photos?limit=${limit}&offset=${offset}`),
uploadPhoto: (file: File) => {
const form = new FormData();
form.append('file', file);
return request<PhotoResponse>('POST', '/photos', { body: form });
},
deletePhoto: (id: string) =>
request<{ success: true }>('DELETE', `/photos/${id}`),
};
```
---
## Rule: Error Handling in Custom Hooks
Every async op in custom hook MUST catch errors and update error state. Optimistic updates MUST roll back on failure.
```typescript
// ❌ BAD — if API call fails, UI state is inconsistent
const deletePhoto = useCallback(async (id: string) => {
setPhotos(prev => prev.filter(p => p.id !== id)); // optimistic removal
await api.deletePhoto(id); // if this throws, photo is gone from UI but not from DB
}, []);
// ✅ GOOD — rollback on failure
const deletePhoto = useCallback(async (id: string) => {
const previousPhotos = photos;
setPhotos(prev => prev.filter(p => p.id !== id)); // optimistic
try {
await api.deletePhoto(id);
} catch (err) {
setPhotos(previousPhotos); // rollback
setError(err instanceof Error ? err.message : 'Failed to delete photo');
}
}, [photos]);
```
### Silent Error Swallowing is a Bug
```typescript
// ❌ BAD — catches ALL errors including network failures, 500s
useEffect(() => {
api.getCouple().then(setCouple).catch(() => {
// "Not paired yet — that's fine"
});
}, []);
// ✅ GOOD — only ignore expected "not found" errors
useEffect(() => {
api.getCouple()
.then(setCouple)
.catch((err) => {
if (err instanceof ApiError && err.status === 404) {
// Not paired yet — expected state
return;
}
setError('Failed to load couple info');
logger.error('Unexpected error loading couple', err);
});
}, []);
```
---
## Rule: No Destructive Actions Without Confirmation
Any action that permanently deletes or irreversibly modifies data MUST require user confirmation before executing.
```typescript
// ❌ BAD — one mis-click deletes a photo permanently
<button onClick={() => onDelete(photo.id)}>Delete</button>
// ✅ GOOD — confirmation required
<button onClick={() => {
if (window.confirm('Delete this photo? This cannot be undone.')) {
onDelete(photo.id);
}
}}>Delete</button>
```
For better UX, consider custom confirmation dialog instead of `window.confirm`.
---
## Pattern: Extract Shared Form Components
When 2+ pages share >50% structure, extract shared component. Common with auth forms.
```typescript
// ❌ BAD — LoginPage and RegisterPage are 90% identical
// LoginPage.tsx: 80 lines of form, state, error handling, submit
// RegisterPage.tsx: 85 lines of nearly identical code
// ✅ GOOD — shared AuthForm component
interface AuthFormProps {
title: string;
fields: { name: string; type: string; label: string; required?: boolean }[];
onSubmit: (data: Record<string, string>) => Promise<void>;
submitLabel: string;
altLink: { text: string; to: string };
}
function AuthForm({ title, fields, onSubmit, submitLabel, altLink }: AuthFormProps) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const formData = new FormData(e.target as HTMLFormElement);
const data = Object.fromEntries(formData) as Record<string, string>;
await onSubmit(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
return (
<div className="form-container">
<h2>{title}</h2>
{error && <p className="error">{error}</p>}
<form onSubmit={handleSubmit}>
{fields.map(field => (
<input key={field.name} name={field.name} type={field.type}
placeholder={field.label} required={field.required} />
))}
<button type="submit" disabled={loading}>
{loading ? 'Please wait...' : submitLabel}
</button>
</form>
<Link to={altLink.to}>{altLink.text}</Link>
</div>
);
}
```
---
## Pattern: File Upload Validation (Client-Side)
File uploads MUST validate client-side before sending to server. Provides immediate feedback, prevents wasted bandwidth.
```typescript
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
function validateFile(file: File): string | null {
if (!ALLOWED_TYPES.includes(file.type)) {
return `Invalid file type: ${file.type}. Allowed: JPEG, PNG, WebP, GIF`;
}
if (file.size > MAX_FILE_SIZE) {
return `File too large: ${(file.size / 1024 / 1024).toFixed(1)} MB. Maximum: 10 MB`;
}
return null; // valid
}
// Usage in upload handler
const handleUpload = async (file: File) => {
const validationError = validateFile(file);
if (validationError) {
setError(validationError);
return;
}
// ... proceed with upload
};
```
**Server MUST also validate** — client-side validation is for UX, not security. Backend upload handler MUST independently check file size and type.
---
## Pattern: Four-State Data Pages
Every page fetching data MUST handle exactly four states:
```typescript
function PhotoGallery() {
const { photos, loading, error } = usePhotos();
// 1. Loading
if (loading) {
return <div className="loading-skeleton">Loading your memories...</div>;
}
// 2. Error (with retry)
if (error) {
return (
<div className="error-state">
<p>Something went wrong: {error}</p>
<button onClick={retry}>Try again</button>
</div>
);
}
// 3. Empty (with helpful CTA)
if (photos.length === 0) {
return (
<div className="empty-state">
<p>No photos yet!</p>
<Link to="/upload">Upload your first memory</Link>
</div>
);
}
// 4. Data
return (
<div className="photo-grid">
{photos.map(photo => <PhotoCard key={photo.id} photo={photo} />)}
</div>
);
}
```
---
## Pattern: Consistent Styling Approach
Choose ONE styling approach and use it consistently. Do not mix inline styles with CSS classes.
| Approach | When to Use | How |
|----------|------------|-----|
| **CSS Modules** | Component-scoped styles, medium-large apps | `import styles from './Button.module.css'` |
| **Global CSS + BEM** | Small apps, rapid prototyping | `className="photo-card__caption"` |
| **CSS-in-JS** (styled-components, Emotion) | Dynamic themes, complex state-based styling | `const Button = styled.button\`...\`` |
| **Tailwind** | Utility-first, design-system projects | `className="flex items-center gap-2"` |
❌ Never mix inline `style={{ }}` props with CSS classes in the same codebase.
---
## Frontend Test Requirements
The frontend test gate (Step 12) requires the following minimum tests:
### Minimum Test Coverage
| Category | What to Test | Example |
|----------|-------------|---------|
| **Auth flow** | Login success, login failure, logout, token expiry redirect | Mock fetch → verify state changes |
| **Protected routes** | Unauthenticated user redirects to /login | Render route without token → expect redirect |
| **Data display** | List renders items from mock API data | Mock fetch → verify items in DOM |
| **Error states** | Error message shown when API returns error | Mock fetch 500 → verify error message |
| **Form validation** | Invalid input shows feedback | Submit empty form → verify error text |
| **Destructive actions** | Delete shows confirmation before executing | Click delete → verify confirm dialog |
### Test Setup Pattern (React + Vitest)
```typescript
// tests/setup.ts
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Mock fetch globally for all tests
global.fetch = vi.fn();
// Helper to mock successful API responses
export function mockFetchSuccess(body: unknown) {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => body,
});
}
// Helper to mock API errors
export function mockFetchError(status: number, error: { code: string; message: string }) {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status,
json: async () => ({ error: { ...error, details: null } }),
});
}
```
### Component Test Example
```typescript
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { mockFetchSuccess, mockFetchError } from '../setup';
describe('ScrapbookPage', () => {
it('renders photo list from API', async () => {
const mockPhotos = [
{ id: '1', caption: 'Beach sunset', blobUrl: '/photo1.jpg', createdAt: '2026-01-01' },
];
mockFetchSuccess({ photos: mockPhotos, total: 1 });
render(<MemoryRouter><ScrapbookPage /></MemoryRouter>);
await waitFor(() => {
expect(screen.getByText('Beach sunset')).toBeInTheDocument();
});
});
it('shows error when API fails', async () => {
mockFetchError(500, { code: 'INTERNAL_ERROR', message: 'Server error' });
render(<MemoryRouter><ScrapbookPage /></MemoryRouter>);
await waitFor(() => {
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});
});
it('shows empty state when no photos', async () => {
mockFetchSuccess({ photos: [], total: 0 });
render(<MemoryRouter><ScrapbookPage /></MemoryRouter>);
await waitFor(() => {
expect(screen.getByText(/no photos yet/i)).toBeInTheDocument();
});
});
});
```
@@ -0,0 +1,373 @@
<!-- azure-cor-disclaimer -->
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
# Frontend Quality Bar — Render Layout Tokens with Real Library Primitives
> **Load this BEFORE writing any frontend page or component.** Read during **Step 1** (Frontend Preview) and **Step 12** (Wire Frontend). This is the contract between the plan's Section 6 (Design System & UI) and the scaffolded JSX.
---
## Shared design-quality principles (fidelity-agnostic — the contract BOTH phases share)
> **This section is the single design-quality contract read by _both_ rendering targets:**
> - **Planning preview** (`azure-project-plan`) — renders these principles as **raw, static HTML/CSS** in `.azure/.preview-temp/` (no framework, no build step, no JavaScript, no network). See the raw-HTML/CSS rendering recipes in [`html-preview.md`](.github/agents/azure-project-plan/references/html-preview.md).
> - **Scaffold app** (`azure-project-scaffold`) — renders the *same* principles with the **real `Component Library` primitives** from Section 6, using the per-library mapping in the rest of this file, and adds the production-only capabilities the static preview cannot show (real photos, motion, dark mode, webfont, library elevation).
>
> Both targets must look like the **same professional product** at two fidelities. The planning preview is **not** a throwaway wireframe — it is a presentation-quality visual specification. The scaffold reproduces it faithfully and must **never regress below it**.
Whatever the rendering target, every page MUST satisfy these six principles:
1. **Presentation quality, not a sketch.** Balanced spacing rhythm, real depth (a multi-tier elevation scale — not a single flat border), consistent corner radius, a coherent themed palette, clear typographic hierarchy, and realistic populated content. The bar is "a screen a designer would show a stakeholder to sign off the look", never a gray boxy wireframe.
2. **Theme from the brand color.** Derive the whole palette from Section 6's `primary` (plus `accent`/surfaces/text/muted/border). The planning preview expresses these as `:root` CSS custom properties; the scaffold wires them through the library's theme provider. Never ship the library's default brand color or hard-coded hexes in markup.
3. **Render layout tokens as real structure.** A `header + hero + grid + footer` layout is real regions with real content — never raw placeholder `<div>`s. (Raw HTML/CSS renders styled semantic elements; the scaffold renders real library primitives per the mapping below.)
4. **All four data states, at least once across the page set.** `data` (populated), `loading` (skeleton), `empty` (illustration/icon + call-to-action), `error` (inline banner + retry). The scaffold makes them reachable live via the Mock State Switcher; the planning preview depicts them statically.
5. **Real iconography.** Named icons with consistent stroke weight — inline `<svg>` in the static preview, the library's official icon set in the scaffold. **Never** emoji, Unicode glyphs, or `viewBox="0 0 1 1"` placeholder shapes.
6. **Every image slot is filled.** No empty tinted boxes. The static preview uses **neutral placeholder blocks** (a subtle surface + border with a muted icon or initials) — **not** brand-color gradients, which overstate how much the palette actually drives the design; the scaffold binds real image URLs from the mock data.
> **Fidelity boundary — what only the scaffold does.** The static planning preview intentionally stops at what raw static files can do well: it uses **system fonts** (no remote webfont), **no JavaScript** (so no working theme toggle, no live state switching — states are depicted statically), and **neutral image placeholders** (no real photos). The scaffold adds real webfonts, motion, working dark mode, live four-state switching, and real imagery. That gap is the scaffold's value-add — it is **not** license for the planning preview to look unpolished.
---
## Core principle
> **Layout tokens are layout INTENT, not implementation.** When the plan's Section 6 says a page's layout is `header + hero + grid + footer`, that does NOT mean produce four `<div>`s with placeholder text. It means **render the equivalent of those regions using the real `Component Library` named in Section 6, themed by the Section 6 Color Palette.**
If you ever emit JSX like this:
```tsx
// ❌ ZERO-EFFORT WIREFRAME REPRODUCTION — DO NOT SHIP
<div className="header">Header</div>
<div className="hero">Hero</div>
<div className="grid">
<div className="card">Card 1</div>
<div className="card">Card 2</div>
</div>
```
…you have failed the quality bar. That output **looks worse than the presentation-quality plan preview** the user already approved, because it strips the theme, the icons, the elevation, and the state coverage the preview shipped. The preview is a promise; this contract is how you keep it.
---
## Section 6 inputs you MUST consume
| Field in plan §5 | What it controls |
|--------------------------|--------------------------------------------------------------------------|
| `Component Library:` | Which library's primitives you import and render (mandatory). |
| `Style Direction:` | Density, corner radius, elevation, list-vs-hero bias. |
| `Typography:` | Font family applied at the app shell level. |
| Color Palette table | Brand ramp / theme tokens — wire through the library's theme provider. |
| Pages table (`Layout`) | Which **library primitives** to compose per page (see mapping below). |
| `.azure/.preview-temp/*.html` + `theme.css` | The **presentation-quality static HTML/CSS preview** the user approved during planning. It is a faithful visual spec: page regions and order, the themed palette, real inline-SVG icons, populated content, and all four data states. What it deliberately omits (raw-static limits): real webfonts, motion, working dark mode, and real photos. **Reproduce it faithfully with real library primitives and add only those production-only capabilities** (see "Polish floor" below) — never regress below it. Do not import, embed, or `<iframe>` it. Folder is deleted in Step 13. |
> If Section 6 is missing or `Component Library:` is blank, **STOP**. The plan is incomplete — re-run `azure-project-plan` instead of guessing.
---
## Per-library region-token → primitive mapping
Pick the row that matches the plan's `Component Library:` value. Every region token below MUST resolve to a real, themed library primitive — not a bare `<div>`. Compound tokens (`split(a|b)`, `two-column(a+b)`) compose two of these in a CSS Grid.
### Fluent UI v9 (`@fluentui/react-components`) — React default
| Region token | Primitive(s) to render |
|--------------|---------------------------------------------------------------------------------------------------------------------|
| `header` | `<Toolbar>` + `<Subtitle1>` (app title) + `<Link>`s + `<Avatar name=… />` on the right |
| `nav` | Horizontal `<TabList>` + `<Tab icon={<HomeRegular />}>` per route |
| `sidebar` | Vertical `<TabList vertical>` + `<Tab icon=…>` per route, with `<Divider>` between groups |
| `hero` | `<Card appearance="filled-alternative">` + `<Title3>` + `<Body1>` + `<Button appearance="primary">` |
| `main` | `<Card>` wrapper with `<CardHeader>` + body content driven by page intent |
| `list` | List of `<Card>` rows with `<CardHeader image={<Avatar />} header=… description=… />` |
| `card-list` | CSS Grid of `<Card>`s with `<CardPreview>` + `<CardHeader>` + `<CardFooter>` (real ratings / metadata, not lorem) |
| `grid` | Same as `card-list` but tighter (e.g. `repeat(auto-fill, minmax(220px, 1fr))`) |
| `form` | `<Field label=… validationState=…>` wrapping `<Input>` / `<Textarea>` / `<Combobox>` / `<Switch>` — at least one field shows `validationState="warning"` with a `validationMessage` so the four-state contract is visible |
| `table` | `<Table>` + `<TableHeader>` + `<TableHeaderCell>`s + `<TableBody>` + `<TableRow>` / `<TableCell>` (real columns) |
| `tabs` | `<TabList>` + `<Tab>`s with the first tab `selected`, second tab content as the visible body |
| `actions` / `action-bar` | `<Toolbar>` + `<ToolbarButton icon=…>`s; primary action uses `appearance="primary"` |
| `modal` | Inline `<Card>` mock (do NOT mount a real `<Dialog>` in the preview — it steals focus and breaks the screenshot) |
| `footer` | `<Divider>` + horizontal `<Caption1>` row with copyright + links |
| unknown | `<MessageBar intent="info">` saying `Unknown layout token "{token}" — will be rendered with {Component Library} in scaffold` |
**Theming**: Wrap the app shell in `<FluentProvider theme={appTheme}>` where `appTheme = createLightTheme(brandRamp)` and `brandRamp` is a 16-step `BrandVariants` derived from Section 6's `primary` color. Body font comes from Section 6's `Typography`.
**Icons**: Use real icons from `@fluentui/react-icons` (Regular variants) — never emoji and never inline SVG hand-drawn placeholders. Reasonable defaults: `HomeRegular`, `SearchRegular`, `SettingsRegular`, `PersonRegular`, `GridRegular`, `DocumentRegular`, `BookmarkRegular`, `MailRegular`, `CalendarRegular`, `ChevronDownRegular`, `AppsRegular`, `TableSimpleRegular`.
### Vuetify 3 — Vue default
| Region token | Primitive(s) to render |
|--------------|--------------------------------------------------------------------------------------------------|
| `header` | `<v-app-bar>` + `<v-app-bar-title>` + `<v-spacer>` + `<v-avatar>` |
| `nav` | `<v-tabs>` + `<v-tab prepend-icon="…">` |
| `sidebar` | `<v-navigation-drawer permanent>` + `<v-list>` + `<v-list-item prepend-icon=…>` |
| `hero` | `<v-card variant="tonal">` + `<v-card-title>` + `<v-card-text>` + `<v-btn color="primary">` |
| `main` | `<v-card>` + `<v-card-title>` + body slot |
| `list` | `<v-list>` of `<v-list-item>` with `prepend-avatar` + `title` + `subtitle` |
| `card-list` / `grid` | `<v-row>` of `<v-col cols="12" sm="6" md="4">` wrapping `<v-card>`s with `<v-img>` + `<v-card-title>` + `<v-card-actions>` |
| `form` | `<v-form>` + `<v-text-field>` / `<v-textarea>` / `<v-select>` / `<v-switch>` (at least one with `:error-messages` set) |
| `table` | `<v-data-table>` with real `headers` + `items` |
| `tabs` | `<v-tabs>` + `<v-tab>` + `<v-window>` + `<v-window-item>` |
| `actions` | `<v-toolbar density="compact">` + `<v-btn color="primary">` |
| `modal` | Inline `<v-card>` (NOT `<v-dialog>` in preview) |
| `footer` | `<v-divider />` + `<v-footer>` row |
| unknown | `<v-alert type="info">Unknown layout token "{token}"</v-alert>` |
**Theming**: Configure `vuetify({ theme: { themes: { light: { colors: { primary: '…', surface: '…', … } } } } })` from Section 6's palette.
### Angular Material — Angular default
| Region token | Primitive(s) to render |
|--------------|------------------------------------------------------------------------------|
| `header` | `<mat-toolbar color="primary">` + title + spacer + `mat-icon-button` |
| `nav` / `sidebar` | `<mat-sidenav-container>` + `<mat-sidenav mode="side" opened>` + `<mat-nav-list>` |
| `hero` | `<mat-card appearance="outlined">` + `<mat-card-title>` + `<button mat-raised-button color="primary">` |
| `list` | `<mat-list>` + `<mat-list-item>` with `<mat-icon matListItemIcon>` |
| `card-list` / `grid` | `<mat-grid-list cols="3">` + `<mat-grid-tile>` + `<mat-card>` |
| `form` | `<mat-form-field appearance="outline">` + `<input matInput>` / `<mat-select>` / `<mat-slide-toggle>` (at least one with `<mat-error>`) |
| `table` | `<table mat-table [dataSource]=…>` with `<th mat-header-cell>` + `<td mat-cell>` |
| `tabs` | `<mat-tab-group>` + `<mat-tab label=…>` |
| `actions` | `<mat-toolbar>` + `<button mat-raised-button color="primary">` |
| `modal` | Inline `<mat-card>` (NOT `<mat-dialog>` in preview) |
| `footer` | `<mat-divider>` + footer row |
| unknown | `<mat-card>` with warning icon + `Unknown layout token "{token}"` |
**Theming**: Define a Material 3 theme via `mat.define-theme(...)` using Section 6's primary as the seed color.
### Skeleton UI (Svelte default)
| Region token | Primitive(s) to render |
|--------------|--------------------------------------------------------------------------------------------------|
| `header` | `<AppBar>` with `lead`, `headline`, `trail` slots |
| `nav` | `<TabGroup>` + `<Tab>`s with `<svelte:fragment slot="lead">` icon |
| `sidebar` | `<AppRail>` + `<AppRailTile>`s, or `<AppShell sidebarLeft>` + `<nav>` + `<a class="list-item">` |
| `hero` | `<div class="card variant-glass-primary p-8">` + `<h2 class="h2">` + `<button class="btn variant-filled-primary">` |
| `list` | `<ul class="list">` + `<li>` with avatar `<Avatar>` + title + subtitle |
| `card-list` / `grid` | CSS Grid of `<div class="card">` blocks with `<header class="card-header">` + body + footer |
| `form` | `<label class="label">` + `<input class="input">` / `<textarea class="textarea">` / `<select class="select">` / `<SlideToggle>` (at least one with `input-error`) |
| `table` | `<Table source={…} />` (Skeleton) or `<table class="table table-hover">` |
| `tabs` | `<TabGroup>` + `<Tab>`s |
| `actions` | `<div class="card-footer">` + `<button class="btn variant-filled-primary">` |
| `modal` | Inline `<div class="card">` (NOT `<Modal>` in preview) |
| `footer` | `<hr class="hr">` + footer `<div>` |
| unknown | `<aside class="alert variant-ghost-surface">Unknown layout token "{token}"</aside>` |
**Theming**: Build a Skeleton theme module from Section 6's palette and set `data-theme="…"` on the root.
### Pico.css / plain HTML
| Region token | Primitive(s) to render |
|--------------|------------------------------------------------------------------------------|
| `header` | `<header><nav><ul>…</ul></nav></header>` (Pico styles `<nav>` automatically) |
| `nav` | `<nav><ul>` of `<li><a>` |
| `sidebar` | `<aside><nav>` of `<li><a>` |
| `hero` | `<article>` + `<hgroup>` + `<button>` |
| `list` / `card-list` / `grid` | `<div class="grid">` of `<article>` cards with `<hgroup>` |
| `form` | `<form>` + `<label>` + `<input>` / `<select>` / `<textarea>` (one with `aria-invalid="true"`) |
| `table` | `<figure><table>` + `<thead>` / `<tbody>` |
| `tabs` | `<nav>` of `<a role="button">` (Pico has no native tabs — flatten to nav) |
| `actions` | `<footer><button class="primary">` |
| `modal` | Inline `<dialog open>` (rendered, not toggled) |
| `footer` | `<footer>` with small text |
| unknown | `<mark>Unknown layout token "{token}"</mark>` |
**Theming**: Set CSS custom properties (`--pico-primary`, `--pico-background-color`, `--pico-color`, `--pico-muted-color`) from Section 6's palette on `:root` or `[data-theme=light]`.
---
## State coverage contract (every data-bearing page)
Every page that displays data MUST cover all four states with real library primitives — not text-only fallbacks:
| State | Fluent UI v9 | Vuetify 3 | Angular Material | Skeleton | Pico |
|----------|-----------------------------------------------|--------------------------------------|-------------------------------------------------|-----------------------------------------|------------------------------------|
| loading | `<Skeleton>` + `<SkeletonItem>` rows | `<v-skeleton-loader type="card">` | `<ngx-skeleton-loader>` or `<mat-progress-bar>` | `<div class="placeholder animate-pulse">` | `<progress indeterminate>` |
| error | `<MessageBar intent="error">` + retry `<Button>` | `<v-alert type="error">` + retry `<v-btn>` | `<mat-card>` + `<mat-icon>error</mat-icon>` + retry button | `<aside class="alert variant-filled-error">` + retry | `<article><mark>` + retry `<button>` |
| empty | `<Card>` with illustration + `<Body1>` + primary `<Button>` CTA | `<v-empty-state>` or `<v-card>` with `<v-icon>` + `<v-btn>` CTA | `<mat-card>` + `<mat-icon>` + primary action | `<div class="card">` + icon + primary CTA `<button>` | `<article>` + `<p>` + primary `<button>` |
| data | Real list/grid/table from mock fixtures | Real list/grid/table | Real list/grid/table | Real list/grid/table | Real list/grid/table |
### Mock State Switcher (STANDARD — always scaffold it)
The four states MUST be reachable from the running preview **without code edits or a server restart**. This is not optional and not a per-project judgement call — **every scaffolded frontend with a data-bearing page ships the same dev-only Mock State Switcher**, wired to a single shared override so reviewers (and the preview gate) can flip `data → loading → empty → error` on demand. Build it to this fixed contract so it behaves identically across projects:
| Piece | Standard |
|-------|----------|
| **Override source** | A single module (`services/web/src/api/previewState.ts`) exposes the current forced state: `type PreviewDataState = 'data' \| 'loading' \| 'empty' \| 'error'`. It reads the initial value from the `?previewState=` URL query param, falls back to `localStorage['previewState']`, then defaults to `'data'`. Setting it updates `localStorage` and notifies subscribers (or reloads). |
| **Mock client honors it** | The mock client (F2) checks `previewState` on every method: `loading` → never resolves (or resolves after a long delay) so skeletons stay up; `error` → rejects with a realistic `Error` so error UI + retry render; `empty` → resolves with empty collections (`[]`) / `null` singletons so empty-state CTAs render; `data` → returns the normal fixtures. One switch, all endpoints — no per-page wiring. |
| **The switcher UI** | A small fixed-corner control (segmented buttons or a `<select>`) rendered **only when `import.meta.env.DEV`**, labelled Data / Loading / Empty / Error, that sets the override. It must never ship in a production build and must sit above app chrome (high `z-index`, `position: fixed`). |
| **Gating** | The entire switcher + override is dev-only. In `import.meta.env.PROD` the override is forced to `'data'` and the UI is not rendered. |
> The switcher rides on the **mock layer only**. When `azure-project-integrate` swaps the seam to the live client, it **removes the switcher as part of the mock-layer teardown** — deleting `src/api/previewState.ts`, the corner-switcher component, and every `previewState` import/usage. Integrate still uses these four states to confirm the four-state contract against live data, but the dev-only switcher MUST NOT survive into the wired-up app.
---
## Theming contract
1. **Build a brand ramp from Section 6's `primary`** and pass it to the library's theme provider — do **not** ship the library's default brand color.
- Fluent UI v9: `createLightTheme(brandRamp)` where `brandRamp: BrandVariants` is a 16-step ramp from HSL lightening/darkening of `primary`.
- Vuetify: `theme.themes.light.colors.primary`.
- Angular Material: `mat.define-theme({ color: { primary: $palette } })`.
- Skeleton: custom theme module with `--color-primary-*` CSS variables.
- Pico: `--pico-primary` CSS variable.
2. **Map `surface` / `text` / `muted` / `border`** onto the library's neutral tokens — do not hard-code colors in component JSX. Semantic states (success / warning / error) come from the library's built-in semantic tokens, not from the plan palette.
3. **Apply `Typography`** at the app shell level (Fluent: `FluentProvider` style override; Vuetify: `<v-app>` font-family; Angular: `--mat-sys-body-large-font`; Skeleton: theme module; Pico: `:root { font-family: … }`).
4. The plan-preview webview renders a sandboxed **HTML/CSS** preview — purely presentational, no JavaScript, no real component library involved. Each page lives at `.azure/.preview-temp/<slug>.html` and shares a single `.azure/.preview-temp/theme.css`. Treat those files as a **presentation-quality visual spec** — approved regions, themed palette, real icons, populated content, and all four states. Reproduce that look with real library primitives and **add the production-only capabilities the static files could not include**: real webfont, motion, dark mode, and real imagery. Do not import, embed, or `<iframe>` the preview. `.azure/.preview-temp/` is deleted in Step 13.
---
## Quality gate (run this checklist before claiming the preview is ready)
- [ ] Every page imports primitives from the library named in Section 6 — **zero raw `<div className="card">` / `<div className="header">` placeholders** outside the layout grid wrappers.
- [ ] App shell is wrapped in the library's theme provider; brand ramp is derived from Section 6 `primary`.
- [ ] Every icon is a real library icon (Fluent: `*Regular` from `@fluentui/react-icons`; Material: `<mat-icon>name</mat-icon>` with real names; Vuetify: `mdi-*`; Skeleton/Pico: native SVG icons via a real icon set such as Lucide or Tabler). **No emoji, no `<svg viewBox="0 0 1 1">` placeholders.**
- [ ] Every `form` region has at least one field with a visible validation state (warning/error) and an inline message.
- [ ] Every data-bearing page exposes all four states (loading / error / empty / data) via a dev-only toggle.
- [ ] `Style Direction:` is reflected in density and corner radius (e.g. "data-dense" → compact toolbars, tight list rows; "calm and spacious" → generous padding, larger cards).
- [ ] No `any` types; the four-state contract still holds; auto-auth still works (if applicable).
- [ ] The scaffolded UI **reproduces the approved preview at `.azure/.preview-temp/<slug>.html` and adds the production-only layer** — same regions, same brand color, same density and populated content, now with real library primitives, real webfont, motion, dark mode, and real imagery the static preview could not include. If a generated page looks **less** polished than the preview, the page has failed the bar.
---
## Polish floor — every scaffolded app, regardless of library
These eight requirements are **non-negotiable**. They are the gap between "the sketch with components swapped in" and "an app the user wants to ship". A page that misses any of them fails the bar.
### 1. Hero treatment (every landing / dashboard / list-index page)
A flat colored panel is **not** a hero. Every hero region MUST have:
- A **brand-gradient card** (linear or radial) using two stops from Section 6's palette (typically `primary` → `accent`, or `primary` → a 12-step-lighter `primary`).
- An **eyebrow line** above the headline: uppercased, letter-spaced ~0.12em, ~11–12px, with a 6px dot prefix — distinct from the headline.
- A **headline** at the library's `display`/`Title1`/`h1` token, max-width ~24ch, line-height ~1.1, sub-tight letter-spacing.
- A **subtitle** at the body token, max-width ~60ch, opacity 0.85–0.95.
- **Two CTAs**: a primary library button (named action, see microcopy below) + a secondary/ghost variant.
- An **ambient SVG mesh backdrop** layered behind the gradient. Concrete pattern:
```tsx
<svg className="hero-mesh" aria-hidden="true" viewBox="0 0 800 400" preserveAspectRatio="none">
<defs>
<radialGradient id="m1" cx="0%" cy="0%" r="80%">
<stop offset="0%" stopColor="var(--brand-accent)" stopOpacity="0.55" />
<stop offset="100%" stopColor="var(--brand-accent)" stopOpacity="0" />
</radialGradient>
<radialGradient id="m2" cx="100%" cy="100%" r="80%">
<stop offset="0%" stopColor="var(--brand-primary)" stopOpacity="0.55" />
<stop offset="100%" stopColor="var(--brand-primary)" stopOpacity="0" />
</radialGradient>
</defs>
<rect width="800" height="400" fill="url(#m1)" />
<rect width="800" height="400" fill="url(#m2)" />
</svg>
```
Position the SVG `position: absolute; inset: 0; pointer-events: none; opacity: 0.7;`. Layer the gradient panel above it. The `aria-hidden` is required.
### 2. Real icons — everywhere (no exceptions)
Every navigation item, sidebar item, KPI tile, empty state, primary CTA, and section title row MUST carry a real, named icon from the library's official icon set:
| Library | Icon source | Concrete sample imports |
|------------------|-----------------------------------|--------------------------------------------------------------------------------------------------|
| Fluent UI v9 | `@fluentui/react-icons` Regular | `HomeRegular`, `SearchRegular`, `SettingsRegular`, `PersonRegular`, `GridRegular`, `BookmarkRegular`, `ChevronRightRegular`, `AddRegular`, `DocumentRegular`, `InboxRegular`, `CalendarRegular`, `ChartMultipleRegular` |
| Vuetify 3 | Material Design Icons (`mdi-*`) | `mdi-home`, `mdi-magnify`, `mdi-cog`, `mdi-account`, `mdi-view-dashboard`, `mdi-bookmark`, `mdi-chevron-right`, `mdi-plus`, `mdi-file-document`, `mdi-inbox`, `mdi-calendar`, `mdi-chart-line` |
| Angular Material | Material Symbols / `mat-icon` | `home`, `search`, `settings`, `person`, `dashboard`, `bookmark`, `chevron_right`, `add`, `description`, `inbox`, `calendar_today`, `show_chart` |
| Skeleton (Svelte)| Lucide-Svelte | `Home`, `Search`, `Settings`, `User`, `LayoutGrid`, `Bookmark`, `ChevronRight`, `Plus`, `FileText`, `Inbox`, `Calendar`, `LineChart` |
| Pico | Lucide (`lucide-static` or `lucide` web) | Same Lucide names above; render via inline SVG or `<i data-lucide="home">` |
**Hard fail**: emoji (🏠, 📊), Unicode glyphs (▲, ★), or hand-drawn `<svg>` shapes used as nav/section/CTA iconography.
### 3. Motion (lightweight, library-aligned)
Every app MUST add motion in three places, with `prefers-reduced-motion` respected:
| Library | Motion tool | Where it must appear |
|------------------|---------------------------------------|-------------------------------------------------------------------------------|
| Fluent UI v9 | `framer-motion` or `motion/react` | Route change (fade+12px slide), card hover (1–2px lift), dialog/popover open |
| Vuetify 3 | `@vueuse/motion` or built-in `<v-fade-transition>` / `<v-slide-y-transition>` | Route change, card hover, dialog open |
| Angular Material | Angular Animations (`@angular/animations`) | Route change (`fadeInUp`), card hover, dialog open |
| Skeleton | Svelte built-in `transition:fade` / `crossfade` | Route change, card hover, drawer open |
Duration: 150–300ms. Easing: `cubic-bezier(0.4, 0, 0.2, 1)` or library default. Wrap all motion in a `prefers-reduced-motion: reduce` media query / `useReducedMotion()` hook — no exceptions.
### 4. All four states — visibly, with library illustrations
The state contract in the table above is the **minimum**. Every empty state MUST also include a real visual element (library illustration component, Lucide/Tabler icon at 64–96px, or a domain-specific SVG), not just text:
- Fluent: empty card with a 64px `*Regular` icon centered above the `<Title3>` + body + primary CTA.
- Vuetify: `<v-empty-state>` (built-in, has illustration slot) or `<v-card>` with `<v-icon size="64">`.
- Angular Material: `<mat-card>` with a `<mat-icon style="font-size: 64px; width: 64px; height: 64px;">`.
- Skeleton: `<div class="card">` with `<Inbox size={64} />` (Lucide) above text.
### 5. Density + radius derived from Style Direction
Translate the `Style Direction:` literal from Section 6 into concrete library tokens:
| Style Direction keyword | Density / radius / weight choices |
|---------------------------------|--------------------------------------------------------------------------------|
| `playful`, `friendly`, `consumer` | Large radii (12–20px on cards, full-pill on buttons), comfortable padding, heavier headlines (700+), saturated palette |
| `professional`, `enterprise`, `serious` | Small radii (4–8px), compact padding, semibold headlines (600), muted palette |
| `editorial`, `magazine`, `content-led` | Mixed radii (cards 4px, hero 0–4px), generous whitespace, large display type, serif option for headings |
| `minimal`, `calm`, `data-dense` | Tight radii (2–6px), compressed padding, regular weight body, restrained palette, hairline 1px borders |
Wire these through the library's density/spacing/radius tokens (Fluent: `tokens.borderRadiusMedium`; Vuetify: `density="compact"` + theme `defaults`; Material: M3 density CSS vars; Skeleton: theme module radius variables).
### 6. Dark mode (required, with persistence)
Every app ships with light + dark themes from day one:
- A theme toggle in the header (uses the library's icon button).
- Persistence via `localStorage` key `app-theme` (`'light' | 'dark' | 'system'`).
- Initial value reads `prefers-color-scheme` when key is missing or set to `'system'`.
- Live update on `matchMedia('(prefers-color-scheme: dark)').addEventListener('change', …)` while in `'system'` mode.
- Both themes derive from the **same** Section 6 brand palette — only neutrals and surfaces flip.
### 7. Real webfont, mapped from Style Direction
Apply a webfont via the `<head>` (CSS `@import` or `<link>`) and wire it through the library's font token. Choose by Style Direction:
| Style Direction keyword | Webfont |
|---------------------------------|------------------------------------|
| `playful`, `friendly` | `Inter` (variable) or `Geist Sans` |
| `professional`, `enterprise` | `Inter` or `IBM Plex Sans` |
| `editorial`, `magazine` | `Source Sans 3` body + `Source Serif 4` headings |
| `minimal`, `calm`, `data-dense` | `Geist Sans` or `IBM Plex Sans` |
Load via `<link rel="preconnect">` + `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=…">` or self-host. Fall back to `system-ui, -apple-system, "Segoe UI", sans-serif`.
### 8. Microcopy — primary CTAs name the action
Primary buttons MUST name the action being committed. **Hard fail**: generic verbs.
| ❌ Generic (fails) | ✅ Named (passes) |
|----------------------|-------------------------------------------------------------------|
| `Submit` | `Save changes`, `Create project`, `Send invite`, `Publish post` |
| `OK` | `Got it`, `Acknowledge`, `Mark as read` |
| `Continue` | `Continue to billing`, `Review and confirm` |
| `Delete` | `Delete project permanently`, `Remove from team` |
Secondary/ghost buttons may be generic (`Cancel`, `Back`). Modal confirm buttons follow the same rule (`Delete 3 items` not `Confirm`).
### 9. Imagery & art direction (domain fit beats stock chrome)
A page can pass items 1–8 and still look templated if it renders empty media surfaces and identical generic cards for every app. Every app MUST:
- **Render real images for every media-bearing entity.** Bind the mock data's image URL (see Sub-step F2) into a real `<img>` / Fluent `<CardPreview image>` / Vuetify `<v-img>` / Angular `mat-card-image` / Skeleton-Pico `<img>` — **never** an empty tinted `<CardPreview>` or a solid-color `<div>` standing in for a photo. A media card with no visible photo is a hard fail.
- **Match the domain's visual idiom.** A photo-sharing app reads as a gallery (edge-to-edge imagery, scrapbook/polaroid framing, captions), a finance app as data-dense tables, an editorial app as a magazine, a chat app as message bubbles. Do not flatten every app into the same generic SaaS card grid.
- **Use bespoke, domain-specific component treatments** — tilted polaroid frames, ticket stubs, chat bubbles, kanban cards, gallery tiles, etc. — **layered on top of** the library primitives from the region-token mapping. This is encouraged, not forbidden (see self-review item 12). The ban is only on empty placeholder `<div>`s that re-skin the wireframe, never on art direction that fits the domain.
---
## Polish self-review checklist (per page, before marking complete)
Run through this 12-item yes/no list for **each page** generated. A "no" on any item means the page is not done. Do not move on.
1. Does the hero use a brand gradient (not flat color) with eyebrow + headline + subtitle + 2 CTAs + ambient SVG mesh backdrop?
2. Does every nav/sidebar item carry a real named icon from the library's icon set (not emoji, not glyph)?
3. Does every KPI tile / section-title row / empty state / primary CTA carry a real icon?
4. Is there at least one motion: route change, card hover, OR dialog/popover open, wired through the library's motion tool, with `prefers-reduced-motion` respected?
5. For data-bearing pages: are all four states (loading / error / empty / data) reachable via a dev toggle, and does the empty state include a 64–96px icon or illustration (not just text)?
6. Is dark mode wired through a header toggle, persisted in `localStorage`, with a `prefers-color-scheme` initial read?
7. Is a real webfont loaded via `<link>` / `@import` and applied through the library's font token?
8. Do density + corner radius tokens reflect Section 6's `Style Direction:` (playful → larger radii; professional → tighter)?
9. Do all primary CTAs name the action (`Save changes`, `Create project`) — no generic `Submit` / `OK` / `Continue`?
10. Is the brand ramp derived from Section 6's `primary` (16-step / theme-provider-driven), with both themes sharing it?
11. Does the page **match or exceed** the approved static preview — same regions and content, now with real library elevation, motion, real webfont, and real imagery — so a reviewer comparing them would say "yes, this is the preview, brought to life" (never "this looks worse than the mock")?
12. Does every region **resolve to a real library primitive** (no zero-effort `<div className="card">Card 1</div>` wireframe stubs)? Bespoke, domain-styled components (polaroid frames, ticket stubs, gallery tiles, chat bubbles) are **encouraged** as long as they wrap or extend a real library primitive and carry real content + imagery — the ban is on empty placeholder `<div>`s that merely re-skin the wireframe, not on domain art direction.
13. Does every media-bearing entity render a **real image** (from the mock data's image URL), not an empty tinted surface or solid-color block?
@@ -0,0 +1,260 @@
<!-- azure-cor-disclaimer -->
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
# Prerequisites
Instructions for detecting which developer tools and VS Code extensions a user has installed, plus a catalog of the tools commonly required to build, run, and debug Azure projects.
There are two phases:
1. **Identify required tools** — derive the required tool set from a set of service criteria (runtime, package manager, project type, orchestrator, etc.).
2. **Inventory what's installed** — check the user's machine to see which of those tools are already installed and ready, recording the install status and version.
---
## Phase 1: Identify required tools
The calling custom agent is responsible for gathering its inputs during this phase — the inputs that describe each service of the project (runtime(s), package manager(s), project type(s), Azure dependencies, orchestrator, execution mode, and so on). How those inputs are obtained is up to the agent, but the agent should come with those in hand.
Those inputs are then used to identify which tools the user should have installed. Phase 1 always derives **both** sets — Run and Debug. The calling custom agent then decides which set(s) to surface — e.g. show only Run, or both — according to its workflow.
These catalogs are not meant to be exhaustive, but illustrative - map any stack/runtime or Azure dependency to the tool that builds or runs it, and assign it to the set that fits.
When the calling agent writes its output table, it should also record **which planned service(s)** require each tool (e.g. `api`, `worker`), using `*` for global toolchain shared by all services (or listing each service explicitly). For a container runtime or Compose provider (Docker or Podman, plus Docker Compose or Podman Compose), list the service(s) whose Azure dependencies its emulators stand in for, rather than `*`.
### Run Tools
Dependencies that are required to run the project locally. If a run tool is missing the project cannot start. These are a list of illustrative examples, they are not exhaustive:
| Tool | Category | Detect with | Needed for |
|------|----------|-------------|------------|
| Node.js | Runtime | `node --version` | node-ts / node-js stacks |
| npm | Package manager | `npm --version` | Node dependency management |
| pnpm | Package manager | `pnpm --version` | Node dependency management (pnpm projects) |
| yarn | Package manager | `yarn --version` | Node dependency management (yarn projects) |
| Python | Runtime | `python --version` (or `python3 --version`) | python stacks |
| pip | Package manager | `pip --version` (or `pip3 --version`) | Python dependency management |
| .NET SDK | Runtime / SDK | `dotnet --version` | dotnet stacks |
| Azure Functions Core Tools | Runtime | `func --version` | Azure Functions backends |
### Debug Tools
Tooling needed to debug the project locally, not just to run it through the terminal. Three independent kinds of entries belong here and all must be evaluated every time: container tooling (a container runtime — **Docker or Podman** — plus its Compose provider) for any Azure-dependency emulators; a Chromium-based browser (Chrome or Edge) for any frontend project type that debugs in a browser; and the VS Code debug-integration extension for each detected project type that has a matching row in the table below. The extensions are required for the debug experience — task types, problem matchers, launch integration — even when the project has no Azure emulator dependencies and even when the matching CLI or runtime tool already appears in the Run group.
This table is the **authoritative list** — include every row whose trigger matches the project, and maintainers must add any new debug tool or extension here so it is considered. Some project types require a specific VS Code extension for the debug experience (e.g. Azure Functions needs the Functions extension for its `func` task type and problem matchers), so do not infer these from memory — take them from this table.
Prefer to use the debug tools listed here. Also, never list VS Code itself — the plan is already running inside VS Code, so it is always present — and never list a VS Code extension for an emulator (e.g. an "Azurite Extension"). Emulators will run as containers via the chosen container runtime (Docker or Podman) and its Compose provider, not as extensions.
| Tool / Extension | Category | Trigger When | Detect with |
|------------------|----------|----------------------|-------------|
| Docker _or_ Podman | Container runtime | Project has Azure dependencies that run as local emulators | `docker --version` **or** `podman --version` |
| Docker Compose _or_ Podman Compose | Compose provider | Orchestrating emulators | `docker compose version` **or** `podman compose version` |
| Chrome or Edge | Browser | Project has a frontend/SPA project type that debugs in a browser | See Browser detection in Phase 2 — detect Chrome/Edge; if neither is found, fall back by OS |
| `ms-azuretools.vscode-azurefunctions` | VS Code extension | Has an Azure Functions service | extensions filesystem check (Phase 2); installed (`✅`) if found, otherwise unknown (`❓`) |
**Container runtime is Docker _or_ Podman — pick one, don't list both.** Docker Desktop and Podman are interchangeable engines for the emulator containers, and the generated `docker-compose.yml` is identical for either. Detect both (see [Container runtime detection](#container-runtime-detection) in Phase 2), then emit prerequisite rows for the **one** the plan will use — Docker + Docker Compose, or Podman + Podman Compose (or Podman via Docker's socket — see below). **Prefer the Podman engine whenever it's ready** — select native Podman if the `podman` CLI is ready (even when Docker is also ready), or Podman-in-Docker-compatibility mode when the `docker` CLI is backed by Podman, unless the user explicitly asked for Docker; fall back to Docker when Podman isn't ready, and default to Docker only when neither can be confirmed. Record the chosen runtime and its Compose command in the plan's Orchestrator table so the generation phase emits matching task commands.
For a frontend project that debugs in a browser, always include a single browser row (Chrome or Edge). Record the **specific browser chosen** (Chrome or Edge) in the row name; the generate phase reads it to pick the frontend debug adapter `type` (`chrome` for Chrome, `msedge` for Edge). See Browser detection in Phase 2.
Always emit a Debug row for every VS Code extension whose project type is present. Run the Phase 2 filesystem check: if the extension folder is found, record it installed (`✅`); otherwise record it unknown (`❓`). Never drop the row just because the extension wasn't found, and never treat it as already covered by a Run tool. For example, an Azure Functions project must include a `ms-azuretools.vscode-azurefunctions` row even though Azure Functions Core Tools already appears under Run — the Core Tools CLI and the extension are separate prerequisites.
---
## Phase 2: Inventory what's installed
For each needed tool, run its detection and record whether it is installed and at what version. Mind any details in the sections below.
Every prerequisite resolves to exactly **two** states:
- **installed (`✅`)** — a detection positively found the tool (a version command returned, or an app/extension folder exists). Record the version when you have it.
- **unknown (`❓`)** — the detection did not find it. This does **not** mean the tool is absent: a version manager or a restricted/sandboxed shell can hide an installed tool, and the extension/Compose lookups fail silently in those shells. So a failed probe is inconclusive. A `❓` is informational; it just tells the user to double-check the tool is installed and, for CLI tools, to run a recheck.
There is no "not-installed" state — never mark a prerequisite with `❌`. When you cannot positively confirm a tool, it is `❓`.
Re-run this inventory whenever the calling agent builds the plan from scratch or regenerates the whole plan, and whenever the tool set itself changes (a runtime edit, or an added/removed service). Do **not** re-run it for a partial regeneration that doesn't touch the tool set — unless the user explicitly asks to recheck prerequisites. Never carry a stale result across a full rebuild.
### Shell environment caveats
The agent's `bash` probes often run in a **non-interactive or sandboxed** shell that never sourced the user's startup files (`~/.zshrc`, `~/.bashrc`, `~/.config/fish/config.fish`, …). Many users expose Node.js, Python, and other runtimes only through a shell version manager — fnm, nvm, asdf, mise, or Volta — that adds its shims to PATH from those startup files. So a tool the user has installed can be invisible to the first probe.
Do **not** work around this by sourcing another shell's rc file from bash — those files can contain shell-specific syntax bash can't parse. Instead retry through the user's own default shell, initialized (see CLI tool detection). And because a failed probe can't tell "genuinely absent" from "hidden by the environment," a tool that fails detection is `❓`, never `❌`.
On **Windows** this rarely applies: PATH is set at the system/user level via the registry, so tools installed with `winget`, `choco`, etc. are visible in every shell without sourcing a profile.
---
### CLI tool detection
Probe each CLI tool (Node.js, npm, pnpm, yarn, Python, pip, `dotnet`, `func`, and any other CLI in the catalog) in two stages, and **stop at the first success**. Record `✅` with the detected version as soon as either stage returns a version. Only if **both** stages fail do you record `❓`.
**Stage 1 — direct check.** Run the catalog's version command directly in the current shell.
```bash
# macOS/Linux — direct probe in the current and likely non-interactive shell.
# `command -v <tool>` gates the version call so we only run it when the tool is
# actually on PATH, and it keeps a shell greeting or "not found" message from
# being mistaken for a version. `2>&1` merges stderr because some tools print
# their version there (e.g. older Python and Java).
command -v node >/dev/null 2>&1 && echo "node:" && node --version 2>&1
```
Reproduce for any tool by substituting its name in all three spots — the `command -v <tool>` gate, the `echo "<tool>:"` label, and the version command. The version flag varies by tool (`--version`, `-v`, `-V`, `version`); use the one from its catalog entry.
**Stage 2 — retry through the user's initialized shell.** If Stage 1 returns nothing, re-run the *same* version command, but this time launch the user's configured default shell (`$SHELL`) as a **login + interactive** shell. This is a single invocation — the `-l` (login) and `-i` (interactive) flags make the shell source the user's startup files as part of starting up, and `-c '<command>'` runs your version command inside that now-initialized environment. There is no separate "start the shell, then send a second command" step; the initialization and the version command happen in one call. That startup is what puts a version manager's shims (fnm, nvm, asdf, mise, Volta) on PATH so the tool becomes visible.
Two guards make this reliable: check `$SHELL` is set and executable first, and gate the version command behind `command -v <tool>` so a shell greeting or startup banner can't be mistaken for a version. Wrap the real output in unique markers and read only the line between them.
```bash
# macOS/Linux — retry through the user's own default shell, initialized.
# Pass -l -i -c as SEPARATE flags: some shells like fish reject the bundled `-lic` form.
# `command -v` gates the version command, and 2>&1 keeps versions printed to
# stderr. A login+interactive shell sources the user's startup files, which can
# emit greetings/banners/MOTD to stdout; the echo markers fence the real version
# output so we parse only the line between them and ignore that noise. They stay
# portable across bash, zsh, fish, etc.
[ -n "$SHELL" ] && [ -x "$SHELL" ] && \
"$SHELL" -l -i -c 'command -v node >/dev/null 2>&1 && echo __COR_START__ && node --version 2>&1 && echo __COR_END__'
```
For example, to confirm Python the same way, swap the tool name in the `command -v` gate and version command (keep the markers as-is):
```bash
[ -n "$SHELL" ] && [ -x "$SHELL" ] && \
"$SHELL" -l -i -c 'command -v python >/dev/null 2>&1 && echo __COR_START__ && python --version 2>&1 && echo __COR_END__'
```
Run Stage 2 for every CLI tool that failed Stage 1. On **Windows**, registry-level PATH means Stage 1 is normally enough:
```powershell
# Windows PowerShell
Get-Command node -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
```
When the inventory produces any `❓` CLI results, tell the user those tools couldn't be confirmed and to run a recheck. The recheck retries detection through the host default shell and can confirm version-manager-provided runtimes the initial sandboxed scan couldn't see; a tool confirmed there flips to `✅`.
---
### Container runtime detection
Only when the project has Azure dependencies that run as local emulators. The emulators run in containers, so the plan needs exactly one container runtime and its Compose provider. **Docker and Podman are interchangeable here** — detect both, then record the one the plan will use.
Follow the same two states as every other prerequisite: **installed (`✅`)** when a probe positively confirms the runtime is present *and ready*, **unknown (`❓`)** otherwise. Never `❌`.
**Step 1 — detect each engine's CLI and Compose provider.** Use the same first-success, two-stage shell approach as CLI tool detection (a version-manager or sandboxed shell can hide an installed tool):
```bash
# Docker
command -v docker >/dev/null 2>&1 && echo "docker:" && docker --version 2>&1
command -v docker >/dev/null 2>&1 && echo "docker compose:" && docker compose version 2>&1
# Podman
command -v podman >/dev/null 2>&1 && echo "podman:" && podman --version 2>&1
command -v podman >/dev/null 2>&1 && echo "podman compose:" && podman compose version 2>&1
```
```powershell
# Windows
Get-Command docker -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
Get-Command podman -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
```
> **`podman compose` needs an external Compose provider.** `podman compose` is a thin wrapper that shells out to `docker-compose` or `podman-compose`; installing the `podman` CLI alone does not guarantee Compose works. Treat Podman's Compose provider as `✅` only when `podman compose version` returns a version.
**Step 2 — confirm the engine is ready, not just installed.** A CLI on PATH is not the same as a running engine.
- **Linux:** the runtime is usually ready once the CLI resolves; a quick `docker info` / `podman info` confirms the daemon/socket is reachable.
- **Windows and macOS:** both engines run containers inside a Linux VM. Docker Desktop must be running; Podman needs a **Podman machine** that exists and is started. Probe it without mutating anything:
```bash
# Is a Podman machine defined and running? (never init/start it here — see the plan phase)
podman machine list --format '{{.Name}} {{.Running}}' 2>&1
```
If no machine exists, or the machine is stopped, record Podman as `❓` and note in the plan that the user must run `podman machine init` / `podman machine start` before F5. **Do not create or start a Podman machine during detection** — that is a slow, stateful action the user should approve (handled in the generation preflight).
**Step 2b — detect Podman running behind Docker's socket (Docker-compatibility mode).** Podman Desktop can expose a **Docker-compatible socket** so plain `docker` / `docker compose` commands actually drive the **Podman** engine. When you detected a working `docker` CLI in Step 1, check which engine actually answers before labeling it Docker:
```bash
# The Docker CLI reports its backing server engine here.
docker info --format '{{.ServerVersion}}' 2>&1 # a Podman backend shows e.g. "5.x.y" with Podman in the full `docker info`
docker version --format '{{.Server.Version}}' 2>&1
```
If the Docker CLI's server identifies as **Podman** (the full `docker info` / `docker version` names Podman, or `podman` is the only engine installed yet `docker` resolves), the machine is running **Podman in Docker-compatibility mode**: the engine is Podman but the Compose command stays `docker compose`. Record this as its own runtime (see Step 3, option for Docker-compat).
**Step 3 — choose the runtime and record it.**
1. If an existing `docker-compose.yml`/`compose.yaml` and project tooling already imply a runtime, keep it (don't override a deliberate existing setup).
2. **Prefer the Podman engine whenever it's ready.**
- If **native Podman** is `✅` (the `podman` CLI is ready — engine reachable, machine running on Win/mac, `podman compose` provider present), select **Podman** with the `podman compose` command — even if Docker is also ready — unless the user explicitly asked for Docker.
- Else if the `docker` CLI is ready **but backed by Podman** (Step 2b), select **Podman (Docker-compatible)**: the engine is Podman, but the Compose command stays **`docker compose`**. Prefer this over plain Docker too, since the engine is still Podman.
3. Otherwise, if Docker is `✅` (backed by the real Docker engine), select **Docker** with `docker compose`.
4. If **neither** can be confirmed, default the plan to **Docker**, record the container runtime + Compose provider as `❓`, and surface the action-required callout so the user installs/starts one before approving.
Record the choice in the plan's Orchestrator table so the generation phase emits the matching Compose command:
| Selection | Container Runtime cell | Compose Command cell |
|-----------|------------------------|----------------------|
| Native Podman | `Podman` | `podman compose` |
| Podman via Docker socket | `Podman (Docker-compatible)` | `docker compose` |
| Docker | `Docker` | `docker compose` |
This preference is the same in autopilot: autopilot selects the Podman engine (native, else Docker-compatible) when it's ready and falls back to Docker, with no chat prompt.
Emit prerequisite rows for the **selected** runtime only (Docker + Docker Compose, or Podman + Podman Compose), and record the same choice — plus its Compose command (`docker compose` or `podman compose`) — in the plan's Orchestrator table so the generation phase emits matching task commands.
---
### VS Code extension detection
Check the extensions filesystem — do **NOT** use `code --list-extensions` (it launches a new VS Code instance). Users may have VS Code, VS Code Insiders, or both — always check all possible locations using `find` (more reliable than piping `ls` through `grep`):
```bash
# macOS/Linux
find ~/.vscode/extensions ~/.vscode-insiders/extensions -maxdepth 1 -name "<extension-id-prefix>*" 2>/dev/null
```
```powershell
# Windows
Get-ChildItem "$env:USERPROFILE\.vscode\extensions", "$env:USERPROFILE\.vscode-insiders\extensions" -Filter "<extension-id-prefix>*" -ErrorAction SilentlyContinue
```
The extensions to check come from the **VS Code debug-integration extensions** table in Phase 1 — that table is the authoritative list. Detect each one with the extensions filesystem check above. If the check finds the extension folder, record it installed (`✅`); if it finds nothing, record it unknown (`❓`) — the scan can come up empty in restricted shells even when the extension is installed.
---
### Browser detection
Only when the project has a frontend/SPA project type that debugs in a browser. Frontend debugging launches a Chromium-based browser — Chrome or Edge — so detect which one is already installed and record **that** browser. The chosen browser drives the generated debug config `type`: Chrome → `chrome`, Edge → `msedge`.
Detect both, then choose in this order:
1. If **Chrome** is installed, choose Chrome and record it installed (`✅`) with its version if available.
2. Otherwise if **Edge** is installed, choose Edge and record it installed (`✅`) with its version if available.
3. If **neither** is detected, fall back by operating system and record the fallback as unknown (`❓`):
- **Windows** → Edge (`msedge`). Edge ships with Windows and is normally detected as installed in step 2, so this fallback rarely triggers.
- **macOS / Linux** → Chrome (`chrome`).
```bash
# macOS — installed if the app bundle exists
ls -d "/Applications/Google Chrome.app" 2>/dev/null # Chrome
ls -d "/Applications/Microsoft Edge.app" 2>/dev/null # Edge
```
```bash
# Linux — installed if any binary resolves
which google-chrome google-chrome-stable chromium chromium-browser 2>/dev/null # Chrome / Chromium
which microsoft-edge microsoft-edge-stable 2>/dev/null # Edge
```
```powershell
# Windows — installed if any path exists
Test-Path "$env:ProgramFiles\Google\Chrome\Application\chrome.exe", "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe" # Chrome
Test-Path "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe", "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe" # Edge
```
---
## Never author install links
Do **not** add an `Install` column, and never emit an install link or URL for any tool — not in the tables, the browser fallback, or anywhere else. The plan webviews append a deterministic Install link resolved from a built-in catalog keyed on the tool name, so any link authored here is ignored, and keeping install URLs out of the plan avoids surfacing an untrusted, model-authored link to the user. Just name the tool accurately (matching the catalog labels above) so the webview can resolve its link.
@@ -0,0 +1,289 @@
<!-- azure-cor-disclaimer -->
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
# Resilience & Graceful Degradation
> Patterns for handling external service failures without crashing requests. Every external call must have a failure plan.
---
## Core Principle
**External service failures MUST NEVER crash the request unless the service is essential to the operation.** Every external dependency gets classified as **Essential** or **Enhancement**, and code must handle each accordingly.
---
## Service Dependency Classification
During Phase 1 planning, classify every external service:
| Type | Definition | Failure Behavior | Example |
|------|-----------|-----------------|---------|
| **Essential** | Request cannot produce meaningful result without this service | Propagate error to client (4xx/5xx) | Database, auth provider |
| **Enhancement** | Request can succeed with degraded output if unavailable | Catch error, use fallback, log warning | AI captions, email notifications, thumbnail generation, analytics |
This classification **MUST appear in the project plan** under "Service Dependency Classification".
---
## Rule: Enhancement Service Constructors Must Not Throw
> ⚠️ **#1 cause of "all tests pass but app doesn't start" failures** (identified across multiple benchmark runs).
Enhancement services must be safe to instantiate even when config is missing. Service registry's `initializeServices()` constructs ALL services at once — if any constructor throws, it cascades to crash every handler.
### The Problem
```typescript
// ❌ BAD — constructor validates config and throws when AZURE_OPENAI_ENDPOINT is empty
class AzureAICaptionService implements IAICaptionService {
private endpoint: string;
constructor() {
const config = loadConfig();
if (!config.openai.endpoint) {
throw new Error('AZURE_OPENAI_ENDPOINT is required'); // 💥 Crashes initializeServices()
}
this.endpoint = config.openai.endpoint;
}
}
```
When `initializeServices()` calls `new AzureAICaptionService()`, the throw prevents registry from initializing. Every subsequent `getServices()` call fails — crashing ALL handlers, even those that never use AI service.
Tests don't catch this because they call `registerServices()` with mocks, bypassing `initializeServices()` entirely.
### Solution A: Defer validation to method calls (Recommended)
```typescript
// ✅ GOOD — constructor never throws; validation happens at call time
class AzureAICaptionService implements IAICaptionService {
private config: AppConfig;
constructor() {
this.config = loadConfig();
// Do NOT validate — Enhancement services must survive missing config
}
async generateCaption(buffer: Buffer, mimeType: string): Promise<string> {
if (!this.config.openai.endpoint || !this.config.openai.apiKey) {
throw new Error('AI caption service not configured');
// Handler's try/catch catches this and uses fallback caption
}
// ... actual implementation
}
async healthCheck(): Promise<boolean> {
return !!(this.config.openai.endpoint && this.config.openai.apiKey);
}
}
```
### Solution B: Wrap construction in registry
```typescript
// ✅ GOOD — registry catches Enhancement service construction failures
function initializeServices(): void {
let aiCaption: IAICaptionService;
try {
aiCaption = new AzureOpenAICaptionService();
} catch (err) {
logger.warn({ err }, 'AI caption service unavailable, using no-op fallback');
aiCaption = {
generateCaption: async () => 'A special moment',
healthCheck: async () => false,
};
}
services = {
database: new PostgresDatabaseService(), // Essential — let it throw
storage: new BlobStorageService(), // Essential — let it throw
aiCaption, // Enhancement — caught above
};
}
```
### Testing: Auto-initialization Test (Mandatory)
Every test suite MUST include a test verifying auto-initialization works without pre-registered mocks:
```typescript
describe('auto-initialization', () => {
it('should survive missing Enhancement service config', () => {
clearServices();
// Only set Essential service env vars
process.env.DATABASE_URL = `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@localhost:5432/testdb`;
process.env.STORAGE_CONNECTION_STRING = 'UseDevelopmentStorage=true';
// Enhancement env vars intentionally NOT set
delete process.env.AZURE_OPENAI_ENDPOINT;
delete process.env.AZURE_OPENAI_API_KEY;
// getServices() must NOT throw
expect(() => getServices()).not.toThrow();
});
});
```
---
## Pattern: Try/Fallback Wrapper
When a feature depends on Enhancement service, wrap call in try/catch with sensible default.
### TypeScript
```typescript
// BAD — AI failure crashes the entire photo upload
const caption = await aiCaption.generateCaption(buffer, mimeType);
// GOOD — AI failure degrades gracefully
let caption: string;
try {
caption = await aiCaption.generateCaption(buffer, mimeType);
} catch (err) {
logger.warn({ err }, 'Caption generation failed, using default');
caption = 'A special moment 📸';
}
```
For Python and C# try/fallback patterns, see [runtimes/python.md](.github/agents/shared-references/runtimes/python.md) and [runtimes/dotnet.md](.github/agents/shared-references/runtimes/dotnet.md).
---
## Pattern: Timeouts
Every external HTTP/SDK call should have timeout to prevent hanging requests.
### TypeScript
```typescript
// Using AbortController (built-in)
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fn();
} finally {
clearTimeout(timeout);
}
}
// Usage
const caption = await withTimeout(
() => aiCaption.generateCaption(buffer, mimeType),
10_000 // 10 seconds
);
```
For Python and C# timeout patterns, see [runtimes/python.md](.github/agents/shared-references/runtimes/python.md) and [runtimes/dotnet.md](.github/agents/shared-references/runtimes/dotnet.md).
---
## Pattern: Retry with Exponential Backoff
For transient failures (429 Too Many Requests, 503 Service Unavailable, network timeouts), retry with increasing delays.
### TypeScript
```typescript
async function withRetry<T>(
fn: () => Promise<T>,
options: { maxRetries?: number; baseDelayMs?: number; retryableErrors?: string[] } = {}
): Promise<T> {
const { maxRetries = 3, baseDelayMs = 500 } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === maxRetries) throw err;
const isRetryable = err instanceof Error && (
err.message.includes('ECONNRESET') ||
err.message.includes('ETIMEDOUT') ||
err.message.includes('429') ||
err.message.includes('503')
);
if (!isRetryable) throw err;
const delay = baseDelayMs * Math.pow(2, attempt);
const jitter = Math.random() * delay * 0.1;
await new Promise(r => setTimeout(r, delay + jitter));
}
}
throw new Error('Unreachable');
}
```
For Python and C# retry patterns, see [runtimes/python.md](.github/agents/shared-references/runtimes/python.md) and [runtimes/dotnet.md](.github/agents/shared-references/runtimes/dotnet.md).
---
## Pattern: Parallel Independent Calls
When multiple independent external calls are needed for a single request, run them in parallel. Combine with try/fallback for Enhancement services.
### TypeScript
```typescript
// BAD — sequential (slow, and second call waits for first)
const blobUrl = await storage.upload('photos', blobName, buffer, file.type);
const caption = await aiCaption.generateCaption(buffer, file.type);
// GOOD — parallel, with fallback on enhancement service
const [blobUrl, caption] = await Promise.all([
storage.upload('photos', blobName, buffer, file.type), // Essential — let it throw
aiCaption.generateCaption(buffer, file.type) // Enhancement — catch
.catch((err) => {
logger.warn({ err }, 'Caption generation failed');
return 'A special moment 📸';
}),
]);
```
For Python and C# parallel call patterns, see [runtimes/python.md](.github/agents/shared-references/runtimes/python.md) and [runtimes/dotnet.md](.github/agents/shared-references/runtimes/dotnet.md).
---
## Testing Resilience
> ⚠️ **MANDATORY for every handler using Enhancement service.** Absence consistently flagged during scaffold benchmarking. Every Enhancement service wrapper must verify graceful degradation with a typed test.
Every Enhancement service wrapper must have tests verifying graceful degradation:
```typescript
describe('uploadPhoto', () => {
it('should succeed with default caption when AI service fails', async () => {
// Arrange: Make AI service throw
const { aiCaption } = getServices();
const mockAI = aiCaption as MockAICaptionService;
mockAI.shouldFail = true;
// Act: Upload should still succeed
const response = await handlers.uploadPhoto(uploadRequest, createMockContext());
// Assert: Photo created with default caption
expect(response.status).toBe(201);
expect(response.jsonBody.photo.caption).toBe('A special moment 📸');
});
it('should fail when storage service fails', async () => {
// Arrange: Make essential service throw
mockStorage.upload = vi.fn().mockRejectedValue(new Error('Storage down'));
// Act & Assert: Upload should fail
const response = await handlers.uploadPhoto(uploadRequest, createMockContext());
expect(response.status).toBe(500);
});
});
```
---
## Checklist
When implementing a function handler that calls external services:
- [ ] Classify each service call as Essential or Enhancement
- [ ] Enhancement services wrapped in try/catch with fallback
- [ ] Essential services propagate errors to error handler
- [ ] Independent calls parallelized with `Promise.all` / `asyncio.gather` / `Task.WhenAll`
- [ ] **Tests verify handler succeeds when Enhancement services fail** (MANDATORY — see Pattern 2 in testing.md)
- [ ] Tests verify handler fails correctly when Essential services fail
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,587 @@
<!-- azure-cor-disclaimer -->
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
# Python Runtime Reference
> Azure Functions v2 model, Python. pytest, Pydantic validation, structlog, DI patterns.
---
## Azure Functions v2 Setup
### Initialization
```bash
func init services/functions --python --model V2
cd services/functions
pip install -r requirements.txt
```
### host.json
```json
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
}
}
},
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
}
}
```
### local.settings.json
```json
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "python",
"AZURE_FUNCTIONS_ENVIRONMENT": "Development",
"ENVIRONMENT": "development",
"STORAGE_CONNECTION_STRING": "UseDevelopmentStorage=true",
"DATABASE_URL": "postgresql://<POSTGRES_USER>:<POSTGRES_PASSWORD>@localhost:5432/appdb",
"REDIS_URL": "redis://localhost:6379"
},
"Host": {
"CORS": "*",
"CORSCredentials": false
}
}
```
> `local.settings.json` is plain JSON and does **not** support `${...}` interpolation.
> Replace `<POSTGRES_USER>` / `<POSTGRES_PASSWORD>` with the same discrete values declared in the
> workspace-root `.env` used by docker-compose. Never leave the angle-bracket placeholders in the
> generated file, and never paste a credential value copied out of tool output — redaction filters
> rewrite concrete credential URLs, and a masked value beginning with `*` corrupts YAML/JSON config.
### pyproject.toml
```toml
[project]
name = "functions"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = [
"azure-functions>=1.17.0",
"pydantic>=2.0.0",
"structlog>=23.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"pytest-cov>=4.0.0",
"ruff>=0.1.0",
"httpx>=0.25.0",
]
# Add per-service dependencies as needed:
# "azure-storage-blob>=12.0.0",
# "psycopg2-binary>=2.9.0",
# "redis>=5.0.0",
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
asyncio_mode = "auto"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W"]
```
---
## Function Handler Pattern
### function_app.py (Registration)
```python
# function_app.py
import azure.functions as func
from services.registry import initialize_services
# Initialize services on cold start
initialize_services()
app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
# Import function handlers — they register themselves via decorators
import functions.get_items # noqa: F401
import functions.create_item # noqa: F401
import functions.get_item_by_id # noqa: F401
import functions.health # noqa: F401
```
### HTTP Function (v2 Model)
```python
# functions/get_items.py
import azure.functions as func
import json
from services.registry import get_services
from errors.error_handler import handle_error
from function_app import app
@app.route(route="items", methods=["GET"])
async def get_items(req: func.HttpRequest) -> func.HttpResponse:
try:
services = get_services()
limit = int(req.params.get("limit", "20"))
offset = int(req.params.get("offset", "0"))
items = await services.database.find_all("items", limit=limit, offset=offset)
return func.HttpResponse(
json.dumps({"items": items, "total": len(items)}),
status_code=200,
mimetype="application/json",
)
except Exception as e:
return handle_error(e)
```
### POST with Validation
```python
# functions/create_item.py
import azure.functions as func
import json
import uuid
from datetime import datetime, timezone
from services.registry import get_services
from errors.error_handler import handle_error
from shared.validation import CreateItemRequest
from function_app import app
@app.route(route="items", methods=["POST"])
async def create_item(req: func.HttpRequest) -> func.HttpResponse:
try:
# Validate input
body = req.get_json()
validated = CreateItemRequest(**body)
services = get_services()
now = datetime.now(timezone.utc).isoformat()
item = {
"id": str(uuid.uuid4()),
**validated.model_dump(),
"created_at": now,
"updated_at": now,
}
created = await services.database.create("items", item)
return func.HttpResponse(
json.dumps({"item": created}),
status_code=201,
mimetype="application/json",
)
except Exception as e:
return handle_error(e)
```
### GET by ID with 404
```python
# functions/get_item_by_id.py
import azure.functions as func
import json
from services.registry import get_services
from errors.error_handler import handle_error
from errors.error_types import NotFoundError
from function_app import app
@app.route(route="items/{id}", methods=["GET"])
async def get_item_by_id(req: func.HttpRequest) -> func.HttpResponse:
try:
services = get_services()
item_id = req.route_params.get("id")
item = await services.database.find_by_id("items", item_id)
if item is None:
raise NotFoundError("Item", item_id)
return func.HttpResponse(
json.dumps({"item": item}),
status_code=200,
mimetype="application/json",
)
except Exception as e:
return handle_error(e)
```
### Health Check
```python
# functions/health.py
import azure.functions as func
import json
from services.registry import get_services
from function_app import app
@app.route(route="health", methods=["GET"])
async def health(req: func.HttpRequest) -> func.HttpResponse:
services = get_services()
checks = {}
try:
checks["database"] = await services.database.health_check()
except Exception:
checks["database"] = False
try:
checks["storage"] = await services.storage.health_check()
except Exception:
checks["storage"] = False
try:
checks["cache"] = await services.cache.health_check()
except Exception:
checks["cache"] = False
all_healthy = all(checks.values())
any_healthy = any(checks.values())
status = "healthy" if all_healthy else ("degraded" if any_healthy else "unhealthy")
return func.HttpResponse(
json.dumps({"status": status, "services": checks}),
status_code=200 if all_healthy else 503,
mimetype="application/json",
)
```
---
## pytest Configuration
### conftest.py (Global Test Setup)
```python
# tests/conftest.py
import pytest
import json
from pathlib import Path
from services.registry import register_services, clear_services, ServiceRegistry
from tests.mocks.mock_database import MockDatabaseService
from tests.mocks.mock_storage import MockStorageService
from tests.mocks.mock_cache import MockCacheService
@pytest.fixture(autouse=True)
def setup_mock_services(item_fixtures):
"""Register mock services before each test, clear after."""
register_services(
ServiceRegistry(
database=MockDatabaseService({"items": item_fixtures["validItems"]}),
storage=MockStorageService(),
cache=MockCacheService(),
)
)
yield
clear_services()
@pytest.fixture
def item_fixtures():
fixture_path = Path(__file__).parent / "fixtures" / "items.json"
with open(fixture_path) as f:
return json.load(f)
@pytest.fixture
def valid_item(item_fixtures):
return item_fixtures["validItems"][0]
@pytest.fixture
def invalid_items(item_fixtures):
return item_fixtures["invalidItems"]
@pytest.fixture
def mock_database(item_fixtures):
return MockDatabaseService({"items": item_fixtures["validItems"]})
```
### Test Examples
```python
# tests/test_get_items.py
import pytest
from unittest.mock import AsyncMock
from services.registry import get_services
async def test_get_items_returns_all_items(item_fixtures):
services = get_services()
items = await services.database.find_all("items")
assert len(items) == len(item_fixtures["validItems"])
async def test_get_items_returns_empty_when_no_data():
from services.registry import register_services, ServiceRegistry
from tests.mocks.mock_database import MockDatabaseService
from tests.mocks.mock_storage import MockStorageService
from tests.mocks.mock_cache import MockCacheService
register_services(
ServiceRegistry(
database=MockDatabaseService(),
storage=MockStorageService(),
cache=MockCacheService(),
)
)
services = get_services()
items = await services.database.find_all("items")
assert items == []
async def test_get_item_by_id_returns_item(valid_item):
services = get_services()
item = await services.database.find_by_id("items", valid_item["id"])
assert item is not None
assert item["id"] == valid_item["id"]
async def test_get_item_by_id_returns_none_for_missing():
services = get_services()
item = await services.database.find_by_id("items", "nonexistent-id")
assert item is None
```
```python
# tests/test_validation.py
import pytest
from pydantic import ValidationError
from shared.validation import CreateItemRequest
def test_valid_create_item():
item = CreateItemRequest(
name="Widget", description="A widget", price=29.99, category="widgets"
)
assert item.name == "Widget"
assert item.price == 29.99
def test_empty_name_fails():
with pytest.raises(ValidationError):
CreateItemRequest(name="", description="A widget", price=29.99, category="widgets")
def test_negative_price_fails():
with pytest.raises(ValidationError):
CreateItemRequest(name="Widget", description="A widget", price=-5.0, category="widgets")
def test_missing_required_fields():
with pytest.raises(ValidationError):
CreateItemRequest(description="Just a description")
```
```python
# tests/test_error_handler.py
import json
from errors.error_types import NotFoundError, ValidationError, BadRequestError
from errors.error_handler import handle_error
def test_not_found_error_returns_404():
error = NotFoundError("Item", "abc-123")
response = handle_error(error)
body = json.loads(response.get_body())
assert response.status_code == 404
assert body["error"]["code"] == "NOT_FOUND"
assert "abc-123" in body["error"]["message"]
def test_validation_error_returns_422():
error = ValidationError("Bad input", details=[{"field": "name", "message": "Required"}])
response = handle_error(error)
body = json.loads(response.get_body())
assert response.status_code == 422
assert body["error"]["code"] == "VALIDATION_ERROR"
def test_unknown_error_returns_500():
error = RuntimeError("Something broke")
response = handle_error(error)
body = json.loads(response.get_body())
assert response.status_code == 500
assert body["error"]["code"] == "INTERNAL_ERROR"
def test_error_response_shape_is_consistent():
errors = [
NotFoundError("Item", "1"),
ValidationError("Bad input"),
BadRequestError("Bad request"),
RuntimeError("Unknown"),
]
for error in errors:
response = handle_error(error)
body = json.loads(response.get_body())
assert "error" in body
assert "code" in body["error"]
assert "message" in body["error"]
```
---
## Validation — Pydantic
### Schema Definition
```python
# shared/validation.py
from pydantic import BaseModel, Field
from typing import Optional
class CreateItemRequest(BaseModel):
name: str = Field(min_length=1, max_length=255)
description: Optional[str] = ""
price: float = Field(gt=0)
category: str = Field(min_length=1, max_length=100)
class UpdateItemRequest(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
price: Optional[float] = Field(None, gt=0)
category: Optional[str] = Field(None, min_length=1, max_length=100)
class PaginationParams(BaseModel):
limit: int = Field(20, ge=1, le=100)
offset: int = Field(0, ge=0)
```
---
## Structured Logging — structlog
### Logger Setup
```python
# logger.py
import structlog
import os
import logging
def setup_logging():
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.StackInfoRenderer(),
structlog.dev.set_exc_info,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer()
if os.environ.get("ENVIRONMENT") == "development"
else structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(
getattr(logging, log_level, logging.INFO)
),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
def get_logger(name: str = "app"):
return structlog.get_logger(name)
```
### Request Logging
```python
# middleware/request_logger.py
import time
from logger import get_logger
logger = get_logger("http")
def log_request(method: str, path: str, status_code: int, start_time: float):
duration_ms = round((time.time() - start_time) * 1000, 2)
logger.info(
"request_completed",
method=method,
path=path,
status=status_code,
duration_ms=duration_ms,
)
```
---
## Shared Types
```python
# shared/types.py
from pydantic import BaseModel
from typing import Optional, Any
from datetime import datetime
class Item(BaseModel):
id: str
name: str
description: str
price: float
category: str
created_at: datetime
updated_at: datetime
class ListItemsResponse(BaseModel):
items: list[Item]
total: int
class SingleItemResponse(BaseModel):
item: Item
class ErrorDetail(BaseModel):
code: str
message: str
details: Optional[Any] = None
class ErrorResponse(BaseModel):
error: ErrorDetail
class HealthResponse(BaseModel):
status: str # "healthy" | "degraded" | "unhealthy"
services: dict[str, bool]
```
---
## Dependencies Quick Reference
### Core Dependencies
| Package | Purpose |
|---------|---------|
| `azure-functions` | Azure Functions v2 runtime |
| `pydantic` | Input validation & shared types |
| `structlog` | Structured logging |
### Per Service
| Service | Package |
|---------|---------|
| Blob Storage | `azure-storage-blob` |
| PostgreSQL | `psycopg2-binary` |
| CosmosDB | `azure-cosmos` |
| Redis | `redis` |
| Migrations | `alembic`, `sqlalchemy` |
### Dev Dependencies
| Package | Purpose |
|---------|---------|
| `pytest` | Test runner |
| `pytest-asyncio` | Async test support |
| `pytest-cov` | Coverage reporting |
| `ruff` | Linting + formatting |
| `httpx` | HTTP client for request-level tests |
@@ -0,0 +1,564 @@
<!-- azure-cor-disclaimer -->
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
# TypeScript (Node.js) Runtime Reference
> Azure Functions v4 model, TypeScript. Test runner setup, validation, logging, DI patterns.
---
## Azure Functions v4 Setup
### Initialization
```bash
func init services/functions --typescript --model V4
cd services/functions
npm install
```
### host.json
```json
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
}
}
},
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
}
}
```
### local.settings.json
```json
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "node",
"AZURE_FUNCTIONS_ENVIRONMENT": "Development",
"NODE_ENV": "development",
"STORAGE_CONNECTION_STRING": "UseDevelopmentStorage=true",
"DATABASE_URL": "postgresql://<POSTGRES_USER>:<POSTGRES_PASSWORD>@localhost:5432/appdb",
"REDIS_URL": "redis://localhost:6379"
},
"Host": {
"CORS": "*",
"CORSCredentials": false
}
}
```
> `local.settings.json` is plain JSON and does **not** support `${...}` interpolation.
> Replace `<POSTGRES_USER>` / `<POSTGRES_PASSWORD>` with the same discrete values declared in the
> workspace-root `.env` used by docker-compose. Never leave the angle-bracket placeholders in the
> generated file, and never paste a credential value copied out of tool output — redaction filters
> rewrite concrete credential URLs, and a masked value beginning with `*` corrupts YAML/JSON config.
### tsconfig.json
> ⚠️ When importing from `../shared/`, `rootDir` must be `".."` for cross-workspace imports. Changes `dist/` output — see `main` field below.
```json
{
"compilerOptions": {
"module": "Node16",
"target": "ES2022",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "..",
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts", "../shared/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}
```
### package.json (backend)
> ⚠️ **`main` field MUST match actual `dist/` output.** With `rootDir: ".."`, handlers compile to `dist/functions/src/functions/*.js` (not `dist/src/functions/*.js`). After `tsc` build, verify `main` resolves via `dist/` contents.
```json
{
"name": "functions",
"version": "1.0.0",
"private": true,
"main": "dist/functions/src/functions/*.js",
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"clean": "rimraf dist",
"prestart": "npm run clean && npm run build",
"start": "func start",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint src/ tests/",
"db:migrate": "knex migrate:latest",
"db:seed": "tsx seeds/seed.ts"
},
"dependencies": {
"@azure/functions": "^4.0.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"rimraf": "^5.0.0",
"vitest": "^1.0.0"
}
}
```
> Adjust `scripts.test` per user's test runner.
> ⚠️ **Deployment build contract — ship a prebuilt artifact, do NOT re-build on the server.**
> The deployed unit for a TS Functions app is the **compiled `dist/`**, not the TypeScript source. `azd` compiles locally (`npm install` + `npm run build`) and packages the output. The server (Kudu/Oryx) then runs `npm install --omit=dev` (production deps only). If the deployed `package.json` still triggers a build, Oryx runs `npm run build` **without** `typescript` (a `devDependency`) and fails with `tsc: not found`. Scaffold the project so the artifact stands alone:
> - **Dependency split is load-bearing.** Every package imported by runtime code (`dist/**/*.js`) MUST be in `dependencies`; build-only tooling (`typescript`, `@types/*`, test runners, bundlers) MUST be in `devDependencies`. A production install (`--omit=dev`) must satisfy every `require`/`import` in `dist/`.
> - **Compiled output must be self-sufficient.** `dist/` must never import from `src/` or need a rebuild to run. The `main` field points at compiled `.js` (Rule 12), never a `.ts` entry.
> - **Prefer disabling the server build over stripping scripts.** The robust deploy config ships the prebuilt `dist/` and turns the platform build **off** (`SCM_DO_BUILD_DURING_DEPLOYMENT=false` / `azd` `host: function` remote build disabled). Do **not** rely on `prepackage`/`postpackage` hooks that temporarily delete the build scripts (`build`, `watch`, `clean`, `prestart`) from `package.json` — that treats the symptom (an unwanted server rebuild) instead of the cause. The deploy agent owns the deploy config; the scaffold's job is to guarantee the split above so a prebuilt artifact deploys cleanly.
> - **Native modules stay external.** Packages with compiled native addons (`bcrypt`, `sharp`, `better-sqlite3`, Prisma engines, …) are OS/arch-specific. Keep them in `dependencies` (installed on the Linux host), never bundle their binaries from a Windows/macOS build.
> ⚠️ **Monorepo / npm workspaces deployment — keep each service self-contained.**
> Each service deploys **independently** (one `azure.yaml` service = one artifact), and npm workspaces **hoist** `node_modules` and the single lockfile to the repo root — so a per-service zip has no local `node_modules` and can't resolve a root-linked dependency on its own (the "43KB zip / `Cannot find module 'shared'`" failures). Use the following approaches to make a service self-contained — **no bundler required by default**:
> - **Compile `shared` in via relative imports (preferred).** Import the shared package by **relative path** (`../../shared/...`), not as a workspace package (`@app/shared`). With `rootDir: ".."` + `../shared` in `include`, `tsc` emits `shared` into the service's own `dist/`, so there is **no `shared` runtime dependency to resolve** — this is the real fix for `Cannot find module 'shared'` on a per-service deploy. If you must consume `shared` as a workspace package, vendor or bundle its compiled output into the artifact.
> - **Resolve third-party deps at the service level.** Because deps are hoisted, ship the service with either (a) a production `node_modules` installed *into the service directory* (isolated `npm install --omit=dev`), or (b) the platform's production install against the service's `package.json` + lockfile (disable only the *build*, keep the *install*). Native-addon packages must install on the Linux host, never copied from a Windows/macOS `node_modules`.
> - **Optional — single-file bundle.** For cold-start-sensitive or single-artifact needs, `esbuild --bundle --packages=external` (from a `src/index.ts` that imports every handler; point `main` at the one `dist/index.js`) inlines first-party code (including `shared`) and leaves npm deps external. It sidesteps both issues but **departs from the idiomatic per-file layout** (Microsoft's `func init --model V4` default is modular handlers + a glob `main` compiled by `tsc`), so treat it as an **opt-in optimization, not the default**.
---
## Function Handler Pattern
> ⚠️ **Always set `status` explicitly**, even for 200. Do NOT omit and rely on default. Explicit status keeps test assertions consistent and prevents `undefined` vs `200` confusion.
### HTTP Function (v4 Model)
```typescript
// services/functions/getItems.ts
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
import { getServices } from '../services/registry';
import { handleError } from '../errors/errorHandler';
import { Item } from '../../shared/types/entities';
app.http('getItems', {
methods: ['GET'],
authLevel: 'anonymous',
route: 'items',
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
try {
const { database } = getServices();
const limit = Number(request.query.get('limit')) || 20;
const offset = Number(request.query.get('offset')) || 0;
const items = await database.findAll<Item>('items', { limit, offset });
return {
status: 200,
jsonBody: { items, total: items.length },
};
} catch (error) {
return handleError(error, context);
}
},
});
```
### POST with Validation
```typescript
// services/functions/createItem.ts
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
import { getServices } from '../services/registry';
import { handleError } from '../errors/errorHandler';
import { validateBody } from '../middleware/validateRequest';
import { createItemSchema } from '../../shared/schemas/validation';
import { v4 as uuid } from 'uuid';
app.http('createItem', {
methods: ['POST'],
authLevel: 'anonymous',
route: 'items',
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
try {
const body = await validateBody(request, createItemSchema);
const { database } = getServices();
const item = {
id: uuid(),
...body,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const created = await database.create('items', item);
return { status: 201, jsonBody: { item: created } };
} catch (error) {
return handleError(error, context);
}
},
});
```
### GET by ID with 404 Handling
```typescript
// services/functions/getItemById.ts
import { app } from '@azure/functions';
import { getServices } from '../services/registry';
import { handleError } from '../errors/errorHandler';
import { NotFoundError } from '../errors/errorTypes';
import { Item } from '../../shared/types/entities';
app.http('getItemById', {
methods: ['GET'],
authLevel: 'anonymous',
route: 'items/{id}',
handler: async (request, context) => {
try {
const { database } = getServices();
const id = request.params.id;
const item = await database.findById<Item>('items', id);
if (!item) {
throw new NotFoundError('Item', id);
}
return { jsonBody: { item } };
} catch (error) {
return handleError(error, context);
}
},
});
```
### Health Check
```typescript
// services/functions/health.ts
import { app } from '@azure/functions';
import { getServices } from '../services/registry';
app.http('health', {
methods: ['GET'],
authLevel: 'anonymous',
route: 'health',
handler: async (request, context) => {
const services = getServices();
const checks: Record<string, boolean> = {};
// Check each service
try { checks.database = await services.database.healthCheck(); } catch { checks.database = false; }
try { checks.storage = await services.storage.healthCheck(); } catch { checks.storage = false; }
try { checks.cache = await services.cache.healthCheck(); } catch { checks.cache = false; }
const allHealthy = Object.values(checks).every(v => v);
const anyHealthy = Object.values(checks).some(v => v);
const status = allHealthy ? 'healthy' : anyHealthy ? 'degraded' : 'unhealthy';
return {
status: allHealthy ? 200 : 503,
jsonBody: { status, services: checks },
};
},
});
```
---
## Test Runner Configurations
### vitest
> ⚠️ **MANDATORY for projects with heavy SDK imports** (pg, @azure/storage-blob, etc.):
> - `fileParallelism: false` — Prevents memory exhaustion from multiple workers loading heavy SDKs.
> - `teardownTimeout: 3000` — Kills lingering connections (e.g., `pg.Pool`) keeping process alive after tests.
> - `testTimeout: 10000` — Prevents false failures on slow CI.
>
> Without these, test suite **hangs indefinitely** with 13+ files importing heavy SDKs. #1 test infrastructure issue.
```typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
setupFiles: ['tests/setup.ts'],
fileParallelism: false,
teardownTimeout: 3000,
testTimeout: 10000,
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/**/interfaces/**', 'src/functions/*.ts'],
},
},
});
```
```typescript
// tests/setup.ts
import { registerServices, clearServices } from '../src/services/registry';
import { MockDatabaseService } from './mocks/mockDatabase';
import { MockStorageService } from './mocks/mockStorage';
import { MockCacheService } from './mocks/mockCache';
import itemFixtures from './fixtures/items.json';
beforeEach(() => {
registerServices({
database: new MockDatabaseService({ items: itemFixtures.validItems }),
storage: new MockStorageService(),
cache: new MockCacheService(),
});
});
afterEach(() => {
clearServices();
});
```
### jest
```typescript
// jest.config.ts
export default {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
setupFilesAfterSetup: ['<rootDir>/tests/setup.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/interfaces/**',
],
};
```
### mocha + chai + sinon
```yaml
# .mocharc.yml
require:
- tsx
- tests/setup.ts
spec: 'tests/**/*.test.ts'
recursive: true
timeout: 10000
```
```typescript
// tests/setup.ts (mocha version)
import { registerServices, clearServices } from '../src/services/registry';
import { MockDatabaseService } from './mocks/mockDatabase';
import { MockStorageService } from './mocks/mockStorage';
import { MockCacheService } from './mocks/mockCache';
import itemFixtures from './fixtures/items.json';
beforeEach(() => {
registerServices({
database: new MockDatabaseService({ items: itemFixtures.validItems }),
storage: new MockStorageService(),
cache: new MockCacheService(),
});
});
afterEach(() => {
clearServices();
});
```
---
## Validation — Zod
### Schema Definition
```typescript
// shared/schemas/validation.ts
import { z } from 'zod';
export const createItemSchema = z.object({
name: z.string().min(1, 'Name is required').max(255),
description: z.string().optional().default(''),
price: z.number().positive('Price must be positive'),
category: z.string().min(1, 'Category is required').max(100),
});
export const updateItemSchema = createItemSchema.partial();
export const paginationSchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
});
export type CreateItemRequest = z.infer<typeof createItemSchema>;
export type UpdateItemRequest = z.infer<typeof updateItemSchema>;
```
---
## Structured Logging — pino
### Logger Setup
```typescript
// src/logger.ts
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
});
export function getLogger(name?: string) {
return name ? logger.child({ module: name }) : logger;
}
```
### Request Logging Middleware
```typescript
// middleware/requestLogger.ts
import { HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
import { getLogger } from '../logger';
const logger = getLogger('http');
export function logRequest(
request: HttpRequest,
response: HttpResponseInit,
context: InvocationContext,
durationMs: number
): void {
logger.info({
method: request.method,
path: request.url,
status: response.status || 200,
durationMs,
functionName: context.functionName,
}, `${request.method} ${request.url} ${response.status || 200} ${durationMs}ms`);
}
```
---
## Shared Types
```typescript
// shared/types/entities.ts
export interface Item {
id: string;
name: string;
description: string;
price: number;
category: string;
createdAt: string;
updatedAt: string;
}
```
```typescript
// shared/types/api.ts
import { Item } from './entities';
// Response contracts
export interface ListItemsResponse {
items: Item[];
total: number;
}
export interface SingleItemResponse {
item: Item;
}
export interface ErrorResponse {
error: {
code: string;
message: string;
details: unknown | null;
};
}
export interface HealthResponse {
status: 'healthy' | 'degraded' | 'unhealthy';
services: Record<string, boolean>;
}
```
---
## ESLint Configuration
```json
// .eslintrc.json
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/explicit-function-return-type": "off",
"no-console": ["warn", { "allow": ["warn", "error"] }]
},
"env": {
"node": true,
"es2022": true
}
}
```
---
## Dependencies Quick Reference
### Core Dependencies
| Package | Purpose |
|---------|---------|
| `@azure/functions` | Azure Functions v4 runtime |
| `zod` | Input validation |
| `pino` | Structured logging |
| `uuid` | ID generation |
### Per Service
| Service | Package |
|---------|---------|
| Blob Storage | `@azure/storage-blob` |
| PostgreSQL | `pg`, `@types/pg` |
| CosmosDB | `@azure/cosmos` |
| Redis | `ioredis` |
| Migrations | `knex` |
### Dev Dependencies
| Package | Purpose |
|---------|---------|
| `typescript` | TypeScript compiler |
| `vitest` / `jest` / `mocha` | Test runner (user's choice) |
| `eslint` + `@typescript-eslint/*` | Linting |
| `prettier` | Formatting |
| `tsx` | TypeScript execution (for scripts) |
| `pino-pretty` | Dev log formatting |
@@ -0,0 +1,300 @@
<!-- azure-cor-disclaimer -->
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
# Seed Data & Migrations
> Database schema management and realistic test data seeding patterns.
---
## Core Principle
**Repeatable, idempotent schema and data management.** Migrations run forward and backward cleanly. Seed data is realistic, usable in both dev and tests. Running seed twice does not duplicate data.
---
## When This Applies
This reference is used **only when project includes a database service** (PostgreSQL, CosmosDB, Azure SQL). If project only uses Blob Storage, Queue Storage, or Redis, skip this reference.
> ⛛ **CRITICAL**: Migration files and seed data scripts MUST contain actual code — not empty files or empty directories. Empty `seeds/migrations/` directory is #1 cause of runtime failures that unit tests cannot catch (mocks use in-memory Maps, not SQL tables). Every table in plan's Database Constraints section MUST have corresponding migration file with complete `CREATE TABLE` statement. Every migration file MUST have both `up()` and `down()` functions. `seeds/fixtures/seed-data.json` MUST contain realistic sample data.
---
## Migration Patterns
### TypeScript — Knex Migrations
#### Setup
```bash
npm install knex pg
npm install -D @types/pg
```
```typescript
// knexfile.ts
import { loadConfig } from './src/services/config';
const config = loadConfig();
export default {
client: 'pg',
connection: config.database.url,
migrations: {
directory: './seeds/migrations',
extension: 'ts',
},
seeds: {
directory: './seeds/data',
extension: 'ts',
},
};
```
#### Migration File
```typescript
// seeds/migrations/20260101000000_create_items.ts
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('items', (table) => {
table.uuid('id').primary().defaultTo(knex.fn.uuid());
table.string('name').notNullable();
table.text('description');
table.decimal('price', 10, 2).notNullable();
table.string('category').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
// Add indexes
await knex.schema.alterTable('items', (table) => {
table.index('category');
table.index('created_at');
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('items');
}
```
#### Seed Script
```typescript
// seeds/seed.ts
import knex from 'knex';
import knexConfig from '../knexfile';
import seedData from './fixtures/seed-data.json';
async function seed() {
const db = knex(knexConfig);
try {
// Run migrations first
await db.migrate.latest();
console.log('Migrations applied.');
// Seed data — idempotent (upsert)
for (const item of seedData.items) {
await db('items')
.insert(item)
.onConflict('id')
.merge();
}
console.log(`Seeded ${seedData.items.length} items.`);
} finally {
await db.destroy();
}
}
seed().catch(console.error);
```
#### Seed Data Fixture
```json
// seeds/fixtures/seed-data.json
{
"items": [
{
"id": "seed-001",
"name": "Sample Widget",
"description": "A sample widget for development",
"price": 29.99,
"category": "widgets",
"created_at": "2026-01-15T10:00:00.000Z",
"updated_at": "2026-01-15T10:00:00.000Z"
},
{
"id": "seed-002",
"name": "Demo Gadget",
"description": "A demo gadget for development",
"price": 49.99,
"category": "gadgets",
"created_at": "2026-02-01T14:00:00.000Z",
"updated_at": "2026-02-01T14:00:00.000Z"
},
{
"id": "seed-003",
"name": "Test Doohickey",
"description": "A test doohickey for development",
"price": 9.99,
"category": "doohickeys",
"created_at": "2026-03-01T08:00:00.000Z",
"updated_at": "2026-03-01T08:00:00.000Z"
}
]
}
```
#### Package.json Scripts
```json
{
"scripts": {
"db:migrate": "knex migrate:latest",
"db:migrate:rollback": "knex migrate:rollback",
"db:seed": "tsx seeds/seed.ts",
"db:reset": "knex migrate:rollback --all && knex migrate:latest && npm run db:seed"
}
}
```
---
### Python and C# Migrations
For Python migration patterns (Alembic + SQLAlchemy), see [runtimes/python.md](.github/agents/shared-references/runtimes/python.md). For C# migration patterns (Entity Framework Core), see [runtimes/dotnet.md](.github/agents/shared-references/runtimes/dotnet.md).
---
## Testing Migrations & Seed Data
### TypeScript Tests
```typescript
// tests/seeds/migration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import knex, { Knex } from 'knex';
import knexConfig from '../../knexfile';
// These tests require a running database (integration tests)
// Skip if DATABASE_URL is not set
const shouldRun = !!process.env.DATABASE_URL;
describe.skipIf(!shouldRun)('Database Migrations', () => {
let db: Knex;
beforeAll(() => {
db = knex(knexConfig);
});
afterAll(async () => {
await db.destroy();
});
it('should run migrations forward', async () => {
await db.migrate.latest();
const tables = await db.raw(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"
);
const tableNames = tables.rows.map((r: any) => r.table_name);
expect(tableNames).toContain('items');
});
it('should run migrations backward', async () => {
await db.migrate.rollback(undefined, true);
const tables = await db.raw(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"
);
const tableNames = tables.rows.map((r: any) => r.table_name);
expect(tableNames).not.toContain('items');
});
it('should be idempotent (run forward twice without error)', async () => {
await db.migrate.latest();
await db.migrate.latest(); // Should not throw
});
});
```
```typescript
// tests/seeds/seedData.test.ts
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
import knex, { Knex } from 'knex';
import knexConfig from '../../knexfile';
import seedData from '../../seeds/fixtures/seed-data.json';
const shouldRun = !!process.env.DATABASE_URL;
describe.skipIf(!shouldRun)('Seed Data', () => {
let db: Knex;
beforeAll(async () => {
db = knex(knexConfig);
await db.migrate.latest();
});
beforeEach(async () => {
await db('items').del(); // Clean slate
});
afterAll(async () => {
await db.migrate.rollback(undefined, true);
await db.destroy();
});
it('should seed correct number of rows', async () => {
for (const item of seedData.items) {
await db('items').insert(item).onConflict('id').merge();
}
const count = await db('items').count('* as total').first();
expect(Number(count?.total)).toBe(seedData.items.length);
});
it('should be idempotent (seeding twice produces same row count)', async () => {
// Seed once
for (const item of seedData.items) {
await db('items').insert(item).onConflict('id').merge();
}
// Seed again
for (const item of seedData.items) {
await db('items').insert(item).onConflict('id').merge();
}
const count = await db('items').count('* as total').first();
expect(Number(count?.total)).toBe(seedData.items.length);
});
});
```
---
## Fixture Data Guidelines
1. **Use realistic data** — Names, descriptions, values should look like real data, not "test1", "test2"
2. **Include edge cases** — Empty strings (where valid), long strings, special characters, Unicode, boundary numbers (0, negative, max)
3. **Use stable IDs** — Seed data should have predictable IDs (e.g., `seed-001`) so tests can reference specific records
4. **Keep fixtures in JSON** — Shared between seed scripts and test fixtures. Easy to read and modify.
5. **Separate seed data from test fixtures** — Seed data populates dev database. Test fixtures drive unit test assertions. May overlap but serve different purposes.
6. **Document fixture schema** — Add comment block or README explaining what each fixture covers
### Example Fixture Structure
```
seeds/
├── seed.ts ← Seed script (runs against real DB)
├── migrations/
│ └── 20260101_create_items.ts
└── fixtures/
└── seed-data.json ← Seed data (realistic dev data)
tests/
├── fixtures/
│ ├── items.json ← Test data (valid + invalid variations)
│ └── users.json ← Test data for another entity
└── mocks/
└── mockDatabase.ts ← Mock service pre-loaded with fixture data
```
@@ -0,0 +1,341 @@
<!-- azure-cor-disclaimer -->
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
# Service Abstraction Layer
> Patterns for testable code that works against local mocks and live Azure services with zero code changes.
---
## Core Principle
Keep two concerns separate:
- **Application user authentication** exists only when the plan says `API Login: Yes`. It authenticates people using the application.
- **Azure client authentication** is never a product choice. Backend-to-Azure communication uses emulator clients in explicit development and managed identity everywhere else.
Both concerns use small application-facing interfaces. Function handlers NEVER import Azure SDKs directly, construct credentials, or parse login tokens themselves. They receive auth services and Azure clients through dependency injection.
> ⚠️ **Auto-initialization requirement**: Service registry's `getServices()` MUST auto-initialize with concrete implementations at runtime. User runs `func start` after `npm run build` — no manual `registerServices()` call, no startup script. Tests override via `registerServices()` with mocks before each test.
> ⚠️ **camelCase↔snake_case conversion requirement**: TypeScript entities use camelCase (`displayName`, `coupleId`) but PostgreSQL columns use snake_case (`display_name`, `couple_id`). Concrete database service MUST handle conversion automatically — snake_case for outbound SQL, camelCase for inbound results. **Mock database does NOT enforce this** (uses plain Maps), so mismatch only surfaces at runtime against real database. Conversion must be built into concrete implementation.
---
## Architecture
```
┌─────────────────────────────────────────────────┐
│ Function Handler │
│ (receives services — no SDK imports) │
├─────────────────────────────────────────────────┤
│ Application-facing interfaces │
│ AuthService │ BlobClientProvider │ Database │
├───────────────┼──────────────────────┼───────────┤
│ User sessions │ Local / managed ID │ Data impl │
└───────────────┴──────────────────────┴───────────┘
```
---
## API login contract
Read the `API Login` value from the approved plan:
- `No`: do not scaffold user login, token middleware, login UI, or mock users.
- `Yes`: define a small auth interface for the behavior the application needs, then scaffold login end to end across the frontend and API.
If the workspace already has an auth system, preserve it. Otherwise use a stack-appropriate REST API flow. A typical implementation verifies a stored password hash, issues a short-lived signed JWT, and verifies its signature, algorithm, issuer, audience, and expiry before protected requests. Put that behavior behind an interface such as `login(credentials)`, `authenticate(request)`, and `getCurrentUser()`. Require signing configuration in production. Never hard-code a signing secret or generate a new production secret at startup.
Managed identity does not authenticate application users. Never use a managed identity access token as a user session.
## Azure client provider contract
Apply this pattern to every Azure service client:
1. Define the smallest interface that expresses the client behavior the application needs. Do not expose SDK types unless the application genuinely operates on them.
2. Implement it once for the local emulator and once for Azure with managed identity. Tests may register an in-memory implementation independently.
3. Select the implementation once in the startup composition root, then inject it into handlers and domain services.
4. Select local only when the platform environment is exactly `Development`. Missing, empty, misspelled, staging, and production values all select the managed-identity implementation.
5. Validate the selected implementation before the app serves requests. Invalid production endpoint configuration must stop startup. Never recover by selecting an emulator, account key, SAS token, API key, or secret-bearing connection string.
6. Keep environment checks out of the rest of the codebase.
7. Keep the interface, both implementations, and factory together or under one provider folder. Use explicit names, document endpoints in `.env.example`, and put `"AZURE_FUNCTIONS_ENVIRONMENT": "Development"` in `local.settings.json`.
This Blob Storage example shows the shape:
```typescript
import { DefaultAzureCredential } from '@azure/identity';
import { BlobServiceClient } from '@azure/storage-blob';
export interface BlobClientProvider {
getClient(): BlobServiceClient;
}
export class AzuriteBlobClientProvider implements BlobClientProvider {
getClient(): BlobServiceClient {
return BlobServiceClient.fromConnectionString(
requireSetting('AZURITE_CONNECTION_STRING'),
);
}
}
export class ManagedIdentityBlobClientProvider implements BlobClientProvider {
getClient(): BlobServiceClient {
const account = requireSetting('AZURE_STORAGE_ACCOUNT');
return new BlobServiceClient(
`https://${account}.blob.core.windows.net`,
new DefaultAzureCredential(),
);
}
}
export function createBlobClientProvider(): BlobClientProvider {
if (process.env.AZURE_FUNCTIONS_ENVIRONMENT === 'Development') {
return new AzuriteBlobClientProvider();
}
return new ManagedIdentityBlobClientProvider();
}
```
Call the factory from the composition root, not from request handlers. Mirror the same boundary in Python with a `Protocol` and in .NET with an interface plus startup DI registration. Use the equivalent managed-identity credential for every other Azure client.
---
## TypeScript Patterns
### Service Interface
```typescript
// services/interfaces/IDatabaseService.ts
export interface IDatabaseService {
findAll<T>(collection: string, options?: QueryOptions): Promise<T[]>;
findById<T>(collection: string, id: string): Promise<T | null>;
findOne<T>(collection: string, filter: Record<string, unknown>): Promise<T | null>;
create<T>(collection: string, data: T): Promise<T>;
update<T>(collection: string, id: string, data: Partial<T>): Promise<T | null>;
delete(collection: string, id: string): Promise<boolean>;
count(collection: string, filter?: Record<string, unknown>): Promise<number>;
healthCheck(): Promise<boolean>;
// Execute multiple operations atomically — all succeed or all rollback.
// The callback receives a transactional IDatabaseService scoped to the transaction.
transaction<T>(fn: (trx: IDatabaseService) => Promise<T>): Promise<T>;
}
export interface QueryOptions {
limit?: number;
offset?: number;
orderBy?: string;
orderDirection?: 'asc' | 'desc';
filter?: Record<string, unknown>;
}
```
```typescript
// services/interfaces/IStorageService.ts
export interface IStorageService {
upload(container: string, name: string, data: Buffer, contentType?: string): Promise<string>;
download(container: string, name: string): Promise<Buffer>;
list(container: string): Promise<string[]>;
delete(container: string, name: string): Promise<void>;
healthCheck(): Promise<boolean>;
}
```
```typescript
// services/interfaces/ICacheService.ts
export interface ICacheService {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
delete(key: string): Promise<void>;
clear(pattern: string): Promise<void>;
healthCheck(): Promise<boolean>;
}
```
### Config Module with Environment Validation
> ⚠️ **Use flat config structure** (not nested objects). Canonical shape — tests and implementation must agree. Flat fields are simpler (`config.databaseUrl` not `config.database.url`), avoid ambiguity when multiple agents scaffold independently.
>
> Only list env vars the project uses. Provider factories validate the settings needed by the selected implementation. **Enhancement service vars** (e.g., `AZURE_OPENAI_ENDPOINT`) are NOT required — accessed via `process.env` directly, may be `undefined`.
```typescript
// services/config.ts
export interface AppConfig {
environment: string;
azureOpenAiEndpoint: string | undefined; // Optional Enhancement service
}
export function requireSetting(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export function loadConfig(): AppConfig {
return {
environment: process.env.AZURE_FUNCTIONS_ENVIRONMENT ?? 'Production',
azureOpenAiEndpoint: process.env.AZURE_OPENAI_ENDPOINT,
};
}
```
Provider constructors or factories call `requireSetting` for the selected implementation's settings. For example, a local PostgreSQL provider may require `DATABASE_URL`, while its production provider requires the Azure PostgreSQL host/database names and obtains an Entra access token with `DefaultAzureCredential`. Do not require local emulator settings in production or production resource settings in explicit development mode. The default environment is `Production`, never `Development`.
### Concrete Implementation (PostgreSQL Example)
> **Important**: Includes camelCase↔snake_case key conversion and `collectionToTable()` mapping. See [examples/service-abstraction-examples.md](.github/agents/shared-references/examples/service-abstraction-examples.md) for complete implementation.
**Key requirements for concrete implementation**:
- `toSnake()`/`toCamel()`/`keysToSnake()`/`keysToCamel()` conversion utilities
- `collectionToTable()` mapping singular collection names to plural SQL table names (e.g., `user` → `users`)
- `create()` and `update()` strip auto-managed fields (`createdAt`, `updatedAt`, `id`) before building SQL
- `transaction()` uses `BEGIN`/`COMMIT`/`ROLLBACK` with pooled client
- `healthCheck()` executes `SELECT 1` wrapped in try/catch
### Mock Implementation (For Tests)
> See [examples/service-abstraction-examples.md](.github/agents/shared-references/examples/service-abstraction-examples.md) for complete `MockDatabaseService`.
**Key requirements for mock**:
- In-memory `Map<string, Map<string, unknown>>` storage (collection → id → item)
- Constructor accepts optional `Record<string, unknown[]>` for initial test data
- `findOne()` iterates store values and matches all filter key-value pairs
- `update()` auto-sets `updatedAt` timestamp
- `transaction()` executes callback directly (no real transaction for unit tests)
- Must replicate same implicit behaviors as concrete (field stripping, timestamp handling)
### Service Registry (DI)
> **Critical**: Registry MUST auto-initialize with concrete implementations at runtime. `func start` must work without manual `registerServices()` call. Tests pre-register mocks via `setup.ts`, overriding auto-initialization.
>
> ⚠️ **`getServices()` MUST lazily call `initializeServices()` when `services === null`.** Registry that throws "Services not initialized" when none pre-registered is BROKEN — `func start` will crash on every request. Correct behavior: if `services` is null, construct concrete implementations from config and cache them.
>
> ⚠️ **Enhancement service safety**: Enhancement services MUST be wrapped in try/catch during construction. If constructor throws, registry must substitute no-op fallback — NOT crash all handlers.
>
> See [examples/service-abstraction-examples.md](.github/agents/shared-references/examples/service-abstraction-examples.md) for complete registry pattern.
**Key requirements for the registry**:
- `registerServices(registry)` — stores provided services (used by tests)
- `getServices()` — returns services; auto-initializes if none registered
- `clearServices()` — resets to null (used in test teardown)
- `initializeServices()` — selects local or production providers once, validates them, and creates concrete instances; Essential services can throw, Enhancement services use an explicit no-op fallback rather than a local implementation
### Usage in Function Handlers
```typescript
// functions/getItems.ts
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
import { getServices } from '../services/registry';
import { handleError } from '../errors/errorHandler';
import { Item } from '../../shared/types/entities';
app.http('getItems', {
methods: ['GET'],
authLevel: 'anonymous',
route: 'items',
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
try {
const { database } = getServices();
const limit = Number(request.query.get('limit')) || 20;
const offset = Number(request.query.get('offset')) || 0;
const items = await database.findAll<Item>('items', { limit, offset });
return { jsonBody: { items, total: items.length } };
} catch (error) {
return handleError(error, context);
}
}
});
```
---
## Python and C# Patterns
For Python service abstraction patterns (Protocol interfaces, config, mock implementations, registry), see [runtimes/python.md](.github/agents/shared-references/runtimes/python.md). For C# (.NET) patterns (interfaces, DI registration, mock implementations), see [runtimes/dotnet.md](.github/agents/shared-references/runtimes/dotnet.md).
---
## Testing Service Abstractions
Every service implementation (real and mock) should be tested:
```typescript
// tests/services/registry.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { registerServices, getServices, clearServices } from '../../src/services/registry';
import { MockDatabaseService } from '../mocks/mockDatabase';
import { MockStorageService } from '../mocks/mockStorage';
import { MockCacheService } from '../mocks/mockCache';
describe('ServiceRegistry', () => {
beforeEach(() => {
clearServices();
});
afterEach(() => {
clearServices();
});
it('should return registered mock services (not auto-initialized ones)', () => {
const registry = {
database: new MockDatabaseService(),
storage: new MockStorageService(),
cache: new MockCacheService(),
};
registerServices(registry);
const services = getServices();
expect(services.database).toBe(registry.database);
expect(services.storage).toBe(registry.storage);
expect(services.cache).toBe(registry.cache);
});
it('should allow re-registration after clearServices', () => {
const first = { database: new MockDatabaseService(), storage: new MockStorageService(), cache: new MockCacheService() };
const second = { database: new MockDatabaseService(), storage: new MockStorageService(), cache: new MockCacheService() };
registerServices(first);
clearServices();
registerServices(second);
expect(getServices().database).toBe(second.database);
});
it('pre-registered mocks take priority over auto-initialization', () => {
const mock = new MockDatabaseService();
registerServices({
database: mock,
storage: new MockStorageService(),
cache: new MockCacheService(),
});
expect(getServices().database).toBe(mock);
});
// ⚠️ MANDATORY — Rule 13 auto-initialization test
// This MUST call getServices() after clearServices() — a test that only
// calls clearServices() without getServices() is a NO-OP and does not
// satisfy the auto-initialization requirement.
it('should auto-initialize without throwing when Enhancement config is missing (Rule 13)', () => {
clearServices();
// Set only Essential service env vars
process.env.DATABASE_URL = `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@localhost:5432/testdb`;
process.env.STORAGE_CONNECTION_STRING = 'UseDevelopmentStorage=true';
// Enhancement env vars intentionally NOT set
delete process.env.AZURE_OPENAI_ENDPOINT;
delete process.env.AZURE_OPENAI_API_KEY;
// getServices() MUST auto-initialize without throwing
expect(() => getServices()).not.toThrow();
const services = getServices();
expect(services.database).toBeDefined();
expect(services.storage).toBeDefined();
expect(services.cache).toBeDefined();
});
});
```