@quanticjs/create-app 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deps.d.ts +4 -0
- package/dist/deps.js +97 -0
- package/dist/deps.js.map +1 -0
- package/dist/generators/backend.d.ts +2 -0
- package/dist/generators/backend.js +20 -0
- package/dist/generators/backend.js.map +1 -0
- package/dist/generators/bff.d.ts +2 -0
- package/dist/generators/bff.js +9 -0
- package/dist/generators/bff.js.map +1 -0
- package/dist/generators/claude.d.ts +2 -0
- package/dist/generators/claude.js +45 -0
- package/dist/generators/claude.js.map +1 -0
- package/dist/generators/docker.d.ts +2 -0
- package/dist/generators/docker.js +10 -0
- package/dist/generators/docker.js.map +1 -0
- package/dist/generators/e2e.d.ts +2 -0
- package/dist/generators/e2e.js +8 -0
- package/dist/generators/e2e.js.map +1 -0
- package/dist/generators/frontend.d.ts +2 -0
- package/dist/generators/frontend.js +28 -0
- package/dist/generators/frontend.js.map +1 -0
- package/dist/generators/module.d.ts +2 -0
- package/dist/generators/module.js +35 -0
- package/dist/generators/module.js.map +1 -0
- package/dist/generators/root.d.ts +2 -0
- package/dist/generators/root.js +7 -0
- package/dist/generators/root.js.map +1 -0
- package/dist/generators/scripts.d.ts +2 -0
- package/dist/generators/scripts.js +10 -0
- package/dist/generators/scripts.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts.d.ts +8 -0
- package/dist/prompts.js +53 -0
- package/dist/prompts.js.map +1 -0
- package/dist/scaffold.d.ts +2 -0
- package/dist/scaffold.js +79 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/utils/exec.d.ts +2 -0
- package/dist/utils/exec.js +14 -0
- package/dist/utils/exec.js.map +1 -0
- package/dist/utils/template.d.ts +10 -0
- package/dist/utils/template.js +20 -0
- package/dist/utils/template.js.map +1 -0
- package/dist/utils/validate.d.ts +4 -0
- package/dist/utils/validate.js +27 -0
- package/dist/utils/validate.js.map +1 -0
- package/package.json +50 -0
- package/templates/backend/app.module.ts.ejs +61 -0
- package/templates/backend/bff.controller.ts.ejs +60 -0
- package/templates/backend/bff.module.ts.ejs +10 -0
- package/templates/backend/bff.service.ts.ejs +6 -0
- package/templates/backend/create-schema.migration.ts.ejs +15 -0
- package/templates/backend/data-source.ts.ejs +13 -0
- package/templates/backend/env.example.ejs +30 -0
- package/templates/backend/main.ts.ejs +43 -0
- package/templates/backend/module.ts.ejs +14 -0
- package/templates/backend/nest-cli.json.ejs +8 -0
- package/templates/backend/package.json.ejs +23 -0
- package/templates/backend/tsconfig.build.json.ejs +4 -0
- package/templates/backend/tsconfig.json.ejs +24 -0
- package/templates/claude/CLAUDE.md.ejs +86 -0
- package/templates/claude/hooks/auto-format.sh +22 -0
- package/templates/claude/hooks/check-secrets.sh +49 -0
- package/templates/claude/hooks/guard-destructive.sh +42 -0
- package/templates/claude/hooks/on-compaction.sh +29 -0
- package/templates/claude/mcp.json +10 -0
- package/templates/claude/rules/api-patterns.md +86 -0
- package/templates/claude/rules/auth-patterns.md +109 -0
- package/templates/claude/rules/backend-patterns.md +421 -0
- package/templates/claude/rules/database-patterns.md +96 -0
- package/templates/claude/rules/docker-patterns.md +86 -0
- package/templates/claude/rules/frontend-patterns.md +262 -0
- package/templates/claude/rules/observability-backend.md +132 -0
- package/templates/claude/rules/observability-frontend.md +49 -0
- package/templates/claude/rules/playwright-mcp.md +80 -0
- package/templates/claude/rules/resilience-ops.md +103 -0
- package/templates/claude/rules/testing-e2e-ui.md +190 -0
- package/templates/claude/rules/testing-patterns.md +94 -0
- package/templates/claude/rules/workflow-backend.md +64 -0
- package/templates/claude/rules/workflow-frontend.md +60 -0
- package/templates/claude/settings.json +68 -0
- package/templates/claude/skills/add-api-endpoint/SKILL.md +59 -0
- package/templates/claude/skills/add-auth-endpoint/SKILL.md +68 -0
- package/templates/claude/skills/add-entity/SKILL.md +56 -0
- package/templates/claude/skills/add-event/SKILL.md +127 -0
- package/templates/claude/skills/add-feature/SKILL.md +20 -0
- package/templates/claude/skills/add-frontend-page/SKILL.md +75 -0
- package/templates/claude/skills/add-handler/SKILL.md +105 -0
- package/templates/claude/skills/add-integration/SKILL.md +176 -0
- package/templates/claude/skills/add-migration/SKILL.md +20 -0
- package/templates/claude/skills/add-module/SKILL.md +89 -0
- package/templates/claude/skills/add-realtime/SKILL.md +119 -0
- package/templates/claude/skills/audit-rules/SKILL.md +120 -0
- package/templates/claude/skills/debugging/SKILL.md +105 -0
- package/templates/claude/skills/docker-dev/SKILL.md +86 -0
- package/templates/claude/skills/e2e-audit/SKILL.md +85 -0
- package/templates/claude/skills/e2e-full/SKILL.md +132 -0
- package/templates/claude/skills/e2e-scan/SKILL.md +171 -0
- package/templates/claude/skills/e2e-verify/SKILL.md +145 -0
- package/templates/claude/skills/fix-bug/SKILL.md +33 -0
- package/templates/claude/skills/implement-spec/SKILL.md +98 -0
- package/templates/claude/skills/review-code/SKILL.md +109 -0
- package/templates/claude/skills/review-spec/SKILL.md +216 -0
- package/templates/claude/skills/run-tests/SKILL.md +37 -0
- package/templates/claude/skills/specify/SKILL.md +87 -0
- package/templates/claude/skills/write-backend-tests/SKILL.md +182 -0
- package/templates/claude/skills/write-ui-tests/SKILL.md +118 -0
- package/templates/docker/Dockerfile.client.ejs +14 -0
- package/templates/docker/Dockerfile.ejs +28 -0
- package/templates/docker/docker-compose.test.yml.ejs +54 -0
- package/templates/docker/docker-compose.yml.ejs +76 -0
- package/templates/docker/nginx.conf.ejs +21 -0
- package/templates/frontend/App.tsx.ejs +64 -0
- package/templates/frontend/DashboardPage.tsx.ejs +37 -0
- package/templates/frontend/LoginPage.tsx.ejs +20 -0
- package/templates/frontend/NotFoundPage.tsx.ejs +15 -0
- package/templates/frontend/api-client.ts.ejs +15 -0
- package/templates/frontend/index.css.ejs +57 -0
- package/templates/frontend/index.html.ejs +13 -0
- package/templates/frontend/main.tsx.ejs +10 -0
- package/templates/frontend/package.json.ejs +16 -0
- package/templates/frontend/playwright.config.ts.ejs +20 -0
- package/templates/frontend/postcss.config.js.ejs +3 -0
- package/templates/frontend/smoke.spec.ts.ejs +37 -0
- package/templates/frontend/tailwind.config.ts.ejs +56 -0
- package/templates/frontend/tsconfig.json.ejs +25 -0
- package/templates/frontend/tsconfig.node.json.ejs +15 -0
- package/templates/frontend/utils.ts.ejs +6 -0
- package/templates/frontend/vite-env.d.ts.ejs +1 -0
- package/templates/frontend/vite.config.ts.ejs +20 -0
- package/templates/root/gitignore.ejs +9 -0
- package/templates/root/prettierrc.ejs +7 -0
- package/templates/scripts/init-db.sh.ejs +8 -0
- package/templates/scripts/save-auth-state.ts.ejs +24 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Notification hook: fires after context compaction.
|
|
3
|
+
# Re-injects key context so Claude doesn't lose track of current work.
|
|
4
|
+
set -uo pipefail
|
|
5
|
+
|
|
6
|
+
echo "=== POST-COMPACTION CONTEXT ==="
|
|
7
|
+
|
|
8
|
+
BRANCH=$(git branch --show-current 2>/dev/null)
|
|
9
|
+
if [[ -n "$BRANCH" ]]; then
|
|
10
|
+
echo "Branch: $BRANCH"
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
COMMITS=$(git log --oneline -10 main..HEAD 2>/dev/null)
|
|
14
|
+
if [[ -n "$COMMITS" ]]; then
|
|
15
|
+
echo ""
|
|
16
|
+
echo "Commits on this branch:"
|
|
17
|
+
echo "$COMMITS"
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
CHANGES=$(git status --short 2>/dev/null | head -20)
|
|
21
|
+
if [[ -n "$CHANGES" ]]; then
|
|
22
|
+
echo ""
|
|
23
|
+
echo "Uncommitted changes:"
|
|
24
|
+
echo "$CHANGES"
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
echo ""
|
|
28
|
+
echo "Re-read CLAUDE.md and .claude/rules/ for conventions."
|
|
29
|
+
echo "=== END ==="
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
globs: "src/**/*.ts"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# API Documentation Patterns
|
|
6
|
+
|
|
7
|
+
## OpenAPI 3.1 — Code-First with @nestjs/swagger
|
|
8
|
+
|
|
9
|
+
| URL | Purpose |
|
|
10
|
+
|-----|---------|
|
|
11
|
+
| `/api/docs` | Swagger UI (interactive) |
|
|
12
|
+
| `/api/docs-json` | OpenAPI 3.1 JSON spec |
|
|
13
|
+
|
|
14
|
+
## Controller Decorators (MANDATORY)
|
|
15
|
+
|
|
16
|
+
Every endpoint annotated with `@ApiOperation`, `@ApiResponse`, `@ApiBody`, `@ApiTags`.
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
@ApiTags('items')
|
|
20
|
+
@Controller('items')
|
|
21
|
+
export class ItemsController {
|
|
22
|
+
@Post()
|
|
23
|
+
@ApiOperation({ summary: 'Create a new item' })
|
|
24
|
+
@ApiResponse({ status: 201, type: ItemResponseDto })
|
|
25
|
+
@ApiResponse({ status: 400, type: ErrorResponseDto })
|
|
26
|
+
create(@Body() dto: CreateItemDto) { ... }
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## DTO Decorators
|
|
31
|
+
|
|
32
|
+
DTOs use both `class-validator` (runtime) and `@ApiProperty` (docs):
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
export class CreateItemDto {
|
|
36
|
+
@ApiProperty({ description: 'Item name', minLength: 1, maxLength: 200 })
|
|
37
|
+
@IsString()
|
|
38
|
+
@MinLength(1)
|
|
39
|
+
@MaxLength(200)
|
|
40
|
+
title: string;
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Response DTOs
|
|
45
|
+
|
|
46
|
+
All API responses use typed response DTOs — never raw entity objects.
|
|
47
|
+
|
|
48
|
+
## Result<T> → HTTP Mapping (RFC 9457 Problem Details)
|
|
49
|
+
|
|
50
|
+
Error responses use `Content-Type: application/problem+json` with RFC 9457 problem-details shape:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"type": "https://arex.dev/errors/NOT_FOUND",
|
|
55
|
+
"title": "Not Found",
|
|
56
|
+
"status": 404,
|
|
57
|
+
"detail": "Item not found",
|
|
58
|
+
"instance": "/api/items/123",
|
|
59
|
+
"correlationId": "abc-123"
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| Result | HTTP |
|
|
64
|
+
|--------|------|
|
|
65
|
+
| `Result.success(value)` | 200/201 |
|
|
66
|
+
| `ErrorType.ValidationError` | 400 |
|
|
67
|
+
| `ErrorType.Unauthorized` | 401 |
|
|
68
|
+
| `ErrorType.Forbidden` | 403 |
|
|
69
|
+
| `ErrorType.NotFound` | 404 |
|
|
70
|
+
| `ErrorType.Conflict` | 409 |
|
|
71
|
+
| `ErrorType.UnprocessableEntity` | 422 |
|
|
72
|
+
| `ErrorType.InternalError` | 500 |
|
|
73
|
+
|
|
74
|
+
## Environment Availability
|
|
75
|
+
|
|
76
|
+
| Env | Swagger UI | JSON Spec |
|
|
77
|
+
|-----|-----------|-----------|
|
|
78
|
+
| Local / Dev / Staging | Enabled | Enabled |
|
|
79
|
+
| **Production** | **Disabled** | **Disabled** |
|
|
80
|
+
|
|
81
|
+
## NEVER
|
|
82
|
+
|
|
83
|
+
- **NEVER** maintain separate Markdown/Wiki API documentation
|
|
84
|
+
- **NEVER** return raw entities from controllers — use response DTOs
|
|
85
|
+
- **NEVER** leave endpoints undocumented
|
|
86
|
+
- **NEVER** enable Swagger UI in production
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
globs: "src/bff/**/*.ts, client/src/**/*.{ts,tsx}"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Authentication & Authorization Patterns
|
|
6
|
+
|
|
7
|
+
## BFF Authentication (Backend-for-Frontend)
|
|
8
|
+
|
|
9
|
+
All auth goes through the NestJS BFF module (`src/bff/`). Tokens stored server-side in Redis. Browser gets httpOnly cookies only.
|
|
10
|
+
|
|
11
|
+
### BFF Endpoints
|
|
12
|
+
|
|
13
|
+
| Endpoint | Method | Purpose |
|
|
14
|
+
|----------|--------|---------|
|
|
15
|
+
| `/auth/login` | GET | Redirect to Keycloak with PKCE. Query params: `provider`, `returnTo` |
|
|
16
|
+
| `/auth/callback` | GET | OIDC callback — exchange code for tokens, set httpOnly cookie, redirect to SPA |
|
|
17
|
+
| `/auth/refresh` | POST | Refresh access token using refresh token from Redis |
|
|
18
|
+
| `/auth/logout` | POST | Clear cookies, revoke Keycloak session, invalidate Redis session |
|
|
19
|
+
| `/auth/me` | GET | Return current user info + role + permissions |
|
|
20
|
+
|
|
21
|
+
### Cookie Configuration
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
{
|
|
25
|
+
httpOnly: true, // invisible to JS — immune to XSS
|
|
26
|
+
secure: true, // HTTPS only (except local dev)
|
|
27
|
+
sameSite: 'lax', // CSRF protection
|
|
28
|
+
path: '/',
|
|
29
|
+
maxAge: 7 * 24 * 3600 // 7 days
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Token Flow
|
|
34
|
+
|
|
35
|
+
1. Browser sends request with httpOnly cookie (automatic — no JS involved)
|
|
36
|
+
2. BFF middleware reads session ID from cookie, looks up access token in Redis
|
|
37
|
+
3. If access token exists → inject `Authorization: Bearer` header
|
|
38
|
+
4. If access token expired but refresh token exists → middleware calls Keycloak to get a new access token, stores it in Redis, injects it (transparent to frontend)
|
|
39
|
+
5. If both expired → no header injected, `JwtAuthGuard` returns 401 (session dead, user must re-login)
|
|
40
|
+
6. `JwtAuthGuard` validates the token as normal
|
|
41
|
+
|
|
42
|
+
- Access tokens: Redis, short-lived (configured in Keycloak)
|
|
43
|
+
- Refresh tokens: Redis, 7 days, rotating
|
|
44
|
+
- Session cookie: browser httpOnly, 7 days
|
|
45
|
+
|
|
46
|
+
### Token Storage (Mobile — React Native)
|
|
47
|
+
|
|
48
|
+
- **Tier 1 (sensitive):** `expo-secure-store` or `react-native-keychain` — tokens, credentials
|
|
49
|
+
- **Tier 2 (preferences):** `AsyncStorage` — theme, locale, onboarding flags
|
|
50
|
+
- **Tier 3 (cached data):** TanStack Query persistence
|
|
51
|
+
|
|
52
|
+
## Authorization — Backend is Source of Truth
|
|
53
|
+
|
|
54
|
+
**Permissions are decided on the backend. Always.** The frontend is a hint layer, not a security boundary.
|
|
55
|
+
|
|
56
|
+
### Frontend Permission Expression
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
const { can } = usePermissions();
|
|
60
|
+
if (can('invoice.delete')) { /* ... */ }
|
|
61
|
+
|
|
62
|
+
<Can permission="invoice.delete">
|
|
63
|
+
<DeleteButton />
|
|
64
|
+
</Can>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Permissions fetched from `/auth/me` on login, refreshed with session.
|
|
68
|
+
|
|
69
|
+
### Frontend Auth State
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
const { data: session } = useQuery({
|
|
73
|
+
queryKey: ['auth', 'session'],
|
|
74
|
+
queryFn: () => api.get('/auth/me'),
|
|
75
|
+
retry: false,
|
|
76
|
+
});
|
|
77
|
+
const isAuthenticated = !!session;
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Login/Logout
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// Login — redirect to Keycloak via BFF
|
|
84
|
+
window.location.href = '/auth/login?provider=google&returnTo=/dashboard';
|
|
85
|
+
|
|
86
|
+
// Logout
|
|
87
|
+
await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Socket.IO
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
const socket = io({ withCredentials: true }); // cookie sent on handshake
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## NEVER
|
|
97
|
+
|
|
98
|
+
- **NEVER** store tokens in `localStorage` or `sessionStorage` (violates RFC 9700)
|
|
99
|
+
- **NEVER** set `Authorization` headers from frontend code
|
|
100
|
+
- **NEVER** read token claims in frontend JS — call `/auth/me`
|
|
101
|
+
- **NEVER** pass tokens in URL query parameters
|
|
102
|
+
- **NEVER** return raw tokens to the frontend
|
|
103
|
+
- **NEVER** bypass the BFF for auth
|
|
104
|
+
- **NEVER** use `AsyncStorage` for tokens on mobile
|
|
105
|
+
- **NEVER** bundle secrets into the mobile app binary
|
|
106
|
+
- **NEVER** implement frontend-only permission checks without backend enforcement
|
|
107
|
+
- **NEVER** use `localStorage`/`sessionStorage` to mock auth in tests — mock `/auth/me` via TanStack Query or `page.route()`
|
|
108
|
+
- **NEVER** implement token refresh logic in the frontend — the BFF middleware handles it server-side (RFC 9700)
|
|
109
|
+
- **NEVER** add 401 retry/intercept logic in the frontend API client — a 401 means the session is dead, not that a token needs refreshing
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
---
|
|
2
|
+
globs: "src/**/*.ts"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Backend Patterns
|
|
6
|
+
|
|
7
|
+
## Modular Monolith
|
|
8
|
+
|
|
9
|
+
Two deployable artifacts: one NestJS backend image, one React frontend image. No microservices.
|
|
10
|
+
|
|
11
|
+
### Module Structure
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
src/
|
|
15
|
+
<module>/ # Domain module (e.g., identity, billing)
|
|
16
|
+
bff/ # BFF authentication
|
|
17
|
+
shared/ # Guards, filters, interceptors
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Module Boundary Rules
|
|
21
|
+
|
|
22
|
+
- Modules communicate through `CommandBus`/`QueryBus` — never import another module's services or repositories
|
|
23
|
+
- Each module owns its own PostgreSQL schema (e.g., `identity.*`, `billing.*`)
|
|
24
|
+
- Async inter-module communication uses Redis Streams
|
|
25
|
+
- Only commands, queries, and DTOs are exported from a module
|
|
26
|
+
|
|
27
|
+
## POST-IMPLEMENTATION CHECKLIST (run after every command/handler pair)
|
|
28
|
+
|
|
29
|
+
Before committing any command + handler:
|
|
30
|
+
- [ ] Command class has `@Validate(XxxValidator)` decorator → grep the command file for `@Validate`
|
|
31
|
+
- [ ] `.validator.ts` file exists with Zod schema + `ICommandValidator<T>`
|
|
32
|
+
- [ ] Handler uses `getTransactionalRepo(this.xxxRepo)` — never `this.xxxRepo` directly
|
|
33
|
+
- [ ] Handler does NOT contain `Result.validationError()` or any `if (x) return Result.failure(...)` validation
|
|
34
|
+
- [ ] Controller only injects `CommandBus`/`QueryBus` — no services, no repositories
|
|
35
|
+
|
|
36
|
+
## Controller Pattern (MANDATORY — thin controllers)
|
|
37
|
+
|
|
38
|
+
Controllers ONLY parse the request and dispatch to command/query bus. No services, no repositories, no business logic.
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { Controller, Post, Get, Body, Param } from '@nestjs/common';
|
|
42
|
+
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
|
43
|
+
|
|
44
|
+
@Controller('items')
|
|
45
|
+
export class ItemsController {
|
|
46
|
+
constructor(
|
|
47
|
+
private readonly commandBus: CommandBus,
|
|
48
|
+
private readonly queryBus: QueryBus,
|
|
49
|
+
) {}
|
|
50
|
+
|
|
51
|
+
@Post()
|
|
52
|
+
async create(@Body() dto: CreateItemDto) {
|
|
53
|
+
return this.commandBus.execute(new CreateItemCommand(dto.name, dto.description));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@Get(':id')
|
|
57
|
+
async findOne(@Param('id') id: string) {
|
|
58
|
+
return this.queryBus.execute(new GetItemByIdQuery(id));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## CQRS Handler Pattern
|
|
64
|
+
|
|
65
|
+
Every feature is a **Command class + CommandHandler** pair. Controllers are thin — they only
|
|
66
|
+
parse the request and dispatch to the command/query bus.
|
|
67
|
+
|
|
68
|
+
All commands are **transactional by default** (UnitOfWork pattern). Nested commands share one transaction.
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import { Validate, DistributedLock, getTransactionalRepo, Result } from '@nestjs-cqrs/quanticjs';
|
|
72
|
+
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
|
73
|
+
import { InjectRepository } from '@nestjs/typeorm';
|
|
74
|
+
import { Repository } from 'typeorm';
|
|
75
|
+
|
|
76
|
+
// Command class — MUST have @Validate decorator (without it, .validator.ts is dead code)
|
|
77
|
+
@Validate(CreateItemValidator) // ← MANDATORY — this wires the validator
|
|
78
|
+
@DistributedLock('create-item:{name}')
|
|
79
|
+
export class CreateItemCommand {
|
|
80
|
+
static readonly logExclude = ['largeField']; // optional: exclude fields from @Log output
|
|
81
|
+
constructor(public readonly name: string, public readonly description: string) {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Handler — uses getTransactionalRepo for UnitOfWork participation
|
|
85
|
+
// ⚠️ NEVER put validation logic in handlers — use @Validate + .validator.ts
|
|
86
|
+
@CommandHandler(CreateItemCommand)
|
|
87
|
+
export class CreateItemHandler implements ICommandHandler<CreateItemCommand> {
|
|
88
|
+
constructor(@InjectRepository(Item) private readonly itemRepo: Repository<Item>) {}
|
|
89
|
+
async execute(command: CreateItemCommand): Promise<Result<ItemDto>> {
|
|
90
|
+
const itemRepo = getTransactionalRepo(this.itemRepo);
|
|
91
|
+
const item = itemRepo.create({ name: command.name, description: command.description });
|
|
92
|
+
await itemRepo.save(item);
|
|
93
|
+
return Result.success(toDto(item));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Pipeline Behavior Chains
|
|
99
|
+
|
|
100
|
+
**Commands:** `Log (global) → FeatureFlag → Validate → Cache → DistributedLock → Transactional (auto) → Handler`
|
|
101
|
+
**Queries:** `Log (global) → FeatureFlag → Validate → Cache → Handler`
|
|
102
|
+
|
|
103
|
+
- `@Log` — global, single structured log per command (payload, duration, result, auto-truncated)
|
|
104
|
+
- `@FeatureFlag('flag-name')` — Unleash feature flag check before execution (see Feature Flags section below)
|
|
105
|
+
- `@Validate(ValidatorClass)` — Zod validation via separate `.validator.ts` class
|
|
106
|
+
- `@Cache('key:{prop}', { ttlSeconds: 60 })` — Redis caching with key interpolation
|
|
107
|
+
- `@DistributedLock('key:{prop}')` — Redis distributed lock for concurrency protection
|
|
108
|
+
- **Transactional** — automatic for all commands (UnitOfWork). Nested commands share one tx
|
|
109
|
+
- `@IsolatedTransaction()` — opt-out: runs in own transaction (audit logs, notifications)
|
|
110
|
+
|
|
111
|
+
### Decorator Reference
|
|
112
|
+
```typescript
|
|
113
|
+
// @Validate — MANDATORY for ALL commands (without it, the .validator.ts is dead code!)
|
|
114
|
+
@Validate(CreateItemValidator)
|
|
115
|
+
export class CreateItemCommand { ... }
|
|
116
|
+
|
|
117
|
+
// @DistributedLock — concurrency protection
|
|
118
|
+
@DistributedLock('create-item:{name}')
|
|
119
|
+
export class CreateItemCommand { ... }
|
|
120
|
+
|
|
121
|
+
// @Cache — read-heavy queries
|
|
122
|
+
@Cache('items:list:{orgId}', { ttlSeconds: 60 })
|
|
123
|
+
export class ListItemsQuery { ... }
|
|
124
|
+
|
|
125
|
+
// @FeatureFlag — see Feature Flags section for naming + fallback rules
|
|
126
|
+
@FeatureFlag('release-billing-premium-export', { fallback: 'throw' })
|
|
127
|
+
export class ExportReportCommand { ... }
|
|
128
|
+
|
|
129
|
+
// @IsolatedTransaction — independent commit (audit, notifications)
|
|
130
|
+
@IsolatedTransaction()
|
|
131
|
+
export class WriteAuditLogCommand { ... }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Validation Pattern (MANDATORY)
|
|
135
|
+
|
|
136
|
+
**Two layers — never mix them:**
|
|
137
|
+
|
|
138
|
+
| Layer | Tool | Where |
|
|
139
|
+
|-------|------|-------|
|
|
140
|
+
| DTO (controller) | class-validator decorators | `*.dto.ts` |
|
|
141
|
+
| Command (pipeline) | Zod + `@Validate(ValidatorClass)` | `*.validator.ts` |
|
|
142
|
+
|
|
143
|
+
**CRITICAL:** Creating a `.validator.ts` file is NOT enough. The command class MUST have `@Validate(XxxValidator)` decorator or the validator never executes.
|
|
144
|
+
|
|
145
|
+
**Handlers MUST NOT contain validation logic.** No `if (x < y) return Result.validationError(...)` in handlers. ALL business rule validation belongs in the Zod validator:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
// ❌ WRONG — validation in handler
|
|
149
|
+
async execute(cmd: RegisterUserCommand): Promise<Result<UserDto>> {
|
|
150
|
+
if (calculateAge(cmd.dateOfBirth) < 18) {
|
|
151
|
+
return Result.validationError('Must be at least 18');
|
|
152
|
+
}
|
|
153
|
+
// ...
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ✅ CORRECT — validation in .validator.ts via Zod, wired with @Validate on command
|
|
157
|
+
import { z } from 'zod';
|
|
158
|
+
import { ICommandValidator, validateCommand } from '@nestjs-cqrs/quanticjs';
|
|
159
|
+
|
|
160
|
+
export class RegisterUserValidator implements ICommandValidator<RegisterUserCommand> {
|
|
161
|
+
private schema = z.object({
|
|
162
|
+
email: z.string().email(),
|
|
163
|
+
dateOfBirth: z.coerce.date().refine(
|
|
164
|
+
dob => calculateAge(dob) >= 18,
|
|
165
|
+
'Must be at least 18 years old'
|
|
166
|
+
),
|
|
167
|
+
password: z.string().min(8).max(128),
|
|
168
|
+
});
|
|
169
|
+
validate(cmd: RegisterUserCommand) { return validateCommand(this.schema, cmd); }
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## UnitOfWork — Nested Command Transactions
|
|
174
|
+
|
|
175
|
+
All commands share an ambient transaction via `AsyncLocalStorage`. Nested commands automatically join the outer transaction — all commit or rollback together.
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
@CommandHandler(OnboardCustomerCommand)
|
|
179
|
+
export class OnboardCustomerHandler {
|
|
180
|
+
async execute(cmd: OnboardCustomerCommand): Promise<Result<void>> {
|
|
181
|
+
const user = await this.commandBus.execute(new CreateUserCommand(...));
|
|
182
|
+
if (!user.isSuccess) return user; // triggers rollback of everything
|
|
183
|
+
const org = await this.commandBus.execute(new CreateOrgCommand(...));
|
|
184
|
+
if (!org.isSuccess) return org; // triggers rollback of everything (including user)
|
|
185
|
+
return Result.success(); // commits everything atomically
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Use `@IsolatedTransaction()` to opt out (audit logs, notifications that must commit independently).
|
|
191
|
+
|
|
192
|
+
## Result<T> Usage
|
|
193
|
+
|
|
194
|
+
Handlers return `Result<T>` — never throw for business errors.
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
// Creating results
|
|
198
|
+
Result.success(value) // happy path
|
|
199
|
+
Result.failure(ErrorType.NotFound, 'message') // typed error
|
|
200
|
+
Result.notFound('message') // shorthand
|
|
201
|
+
Result.conflict('message') // shorthand
|
|
202
|
+
Result.forbidden('message') // shorthand
|
|
203
|
+
Result.unauthorized('message') // shorthand
|
|
204
|
+
Result.unprocessableEntity('message') // shorthand
|
|
205
|
+
Result.validationError('message') // ONLY from validators, never handlers
|
|
206
|
+
|
|
207
|
+
// Using results
|
|
208
|
+
result.isSuccess // boolean check
|
|
209
|
+
result.value // access value (undefined if failure)
|
|
210
|
+
result.unwrap() // get value or throw if failure
|
|
211
|
+
result.map(item => toDto(item)) // transform value, preserving error state
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Entity Patterns
|
|
215
|
+
|
|
216
|
+
All entities extend `BaseEntity` from `@nestjs-cqrs/quanticjs`:
|
|
217
|
+
- `id` (UUID, auto-generated)
|
|
218
|
+
- `createdAt` (timestamp)
|
|
219
|
+
- `updatedAt` (timestamp)
|
|
220
|
+
|
|
221
|
+
Tenant-scoped entities extend `TenantBaseEntity` (adds `organizationId`).
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
import { BaseEntity, TenantBaseEntity, Result } from '@nestjs-cqrs/quanticjs';
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Redis Streams — Inter-Module Events
|
|
228
|
+
|
|
229
|
+
- Use `RedisStreamConsumer` base class for consumers (handles connection isolation)
|
|
230
|
+
- Every consumer uses a **dedicated cloned connection** for blocking XREADGROUP
|
|
231
|
+
- Shared `REDIS_CLIENT` is for non-blocking ops only (cache, XADD, locks)
|
|
232
|
+
- `onModuleInit()` for setup; `onApplicationBootstrap()` for polling loops
|
|
233
|
+
- Events published after transaction commits
|
|
234
|
+
|
|
235
|
+
### Retry and Dead-Letter Policy
|
|
236
|
+
|
|
237
|
+
- **Max retries:** 5 with exponential backoff + jitter (`min(1s × 2^attempt + random(0,1000ms), 30s)`)
|
|
238
|
+
- **Dead-letter stream:** `{stream}:dlq` (e.g., `orders:events:dlq`) with `MAXLEN ~ 100000`
|
|
239
|
+
- Failed events are never silently dropped — retry or dead-letter
|
|
240
|
+
|
|
241
|
+
## NestJS Module Patterns — Singletons and Lifecycle
|
|
242
|
+
|
|
243
|
+
### .forRoot() Modules — Import ONCE in app.module.ts
|
|
244
|
+
|
|
245
|
+
Modules with `.forRoot()` (ScheduleModule, LoggerModule, BullModule, etc.) MUST be imported
|
|
246
|
+
**exactly once** in `app.module.ts`. Feature modules import the regular module (no `.forRoot()`).
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
// ❌ WRONG — .forRoot() in every feature module
|
|
250
|
+
@Module({ imports: [ScheduleModule.forRoot()] })
|
|
251
|
+
export class ActivityModule {}
|
|
252
|
+
|
|
253
|
+
@Module({ imports: [ScheduleModule.forRoot()] })
|
|
254
|
+
export class BillingModule {}
|
|
255
|
+
|
|
256
|
+
// ✅ CORRECT — .forRoot() once in app.module.ts
|
|
257
|
+
@Module({
|
|
258
|
+
imports: [
|
|
259
|
+
ScheduleModule.forRoot(), // once here
|
|
260
|
+
LoggerModule.forRoot(pinoConfig), // once here
|
|
261
|
+
BullModule.forRoot({ redis }), // once here
|
|
262
|
+
ActivityModule,
|
|
263
|
+
BillingModule,
|
|
264
|
+
],
|
|
265
|
+
})
|
|
266
|
+
export class AppModule {}
|
|
267
|
+
|
|
268
|
+
// Feature modules just use the schedule decorators — no .forRoot() needed
|
|
269
|
+
@Module({ providers: [ActivityCleanupService] })
|
|
270
|
+
export class ActivityModule {}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**Common .forRoot() modules that must be in app.module.ts only:**
|
|
274
|
+
- `ScheduleModule.forRoot()` — @nestjs/schedule
|
|
275
|
+
- `LoggerModule.forRoot()` — nestjs-pino
|
|
276
|
+
- `BullModule.forRoot()` — @nestjs/bull
|
|
277
|
+
- `EventEmitterModule.forRoot()` — @nestjs/event-emitter
|
|
278
|
+
- `ThrottlerModule.forRoot()` — @nestjs/throttler
|
|
279
|
+
|
|
280
|
+
### Lifecycle Hooks — Setup vs. Async Work
|
|
281
|
+
|
|
282
|
+
**`onModuleInit()`** — for synchronous setup (create consumer groups, register handlers).
|
|
283
|
+
**`onApplicationBootstrap()`** — for starting async work (polling loops, listeners).
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
// ❌ WRONG — blocking poll loop in onModuleInit prevents app.listen()
|
|
287
|
+
@Injectable()
|
|
288
|
+
export class EventConsumer implements OnModuleInit {
|
|
289
|
+
async onModuleInit() {
|
|
290
|
+
await this.createConsumerGroup();
|
|
291
|
+
while (true) { // 🔥 blocks forever — app never starts
|
|
292
|
+
await this.redis.xreadgroup('GROUP', 'mygroup', 'consumer', 'BLOCK', 5000, ...);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ✅ CORRECT — setup in onModuleInit, polling in onApplicationBootstrap
|
|
298
|
+
@Injectable()
|
|
299
|
+
export class EventConsumer implements OnModuleInit, OnApplicationBootstrap {
|
|
300
|
+
async onModuleInit() {
|
|
301
|
+
// Synchronous setup only — create consumer groups, register handlers
|
|
302
|
+
await this.createConsumerGroup();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async onApplicationBootstrap() {
|
|
306
|
+
// App is fully started — safe to begin async polling
|
|
307
|
+
this.startPolling(); // non-blocking — fires and forgets
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private async startPolling() {
|
|
311
|
+
while (this.isRunning) {
|
|
312
|
+
const messages = await this.redis.xreadgroup(...);
|
|
313
|
+
// process messages
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**Why this matters:** Multiple Redis stream consumers each calling `XREADGROUP BLOCK` in `onModuleInit()` cascade-block the entire startup. The app never reaches `app.listen()`.
|
|
320
|
+
|
|
321
|
+
### Redis Connection Isolation — Blocking vs. Non-Blocking
|
|
322
|
+
|
|
323
|
+
`XREADGROUP BLOCK` monopolizes the TCP connection for the block duration. If consumers share
|
|
324
|
+
the `REDIS_CLIENT` singleton, non-blocking callers (cache GET/SET, distributed lock SET NX,
|
|
325
|
+
XADD publish) queue behind the blocked read — causing multi-second latency spikes.
|
|
326
|
+
|
|
327
|
+
**Rule:** Every stream consumer MUST use a **dedicated cloned connection** for blocking reads.
|
|
328
|
+
The shared `REDIS_CLIENT` is for non-blocking ops only.
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
// ❌ WRONG — blocking read on the shared client starves cache/locks
|
|
332
|
+
@Injectable()
|
|
333
|
+
export class EventConsumer {
|
|
334
|
+
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}
|
|
335
|
+
|
|
336
|
+
async poll() {
|
|
337
|
+
await this.redis.xreadgroup('GROUP', 'g', 'c', 'BLOCK', 5000, 'STREAMS', 'key', '>');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ✅ CORRECT — RedisStreamConsumer base class handles this automatically
|
|
342
|
+
// It clones a dedicated connection via redis.duplicate() for blocking XREADGROUP,
|
|
343
|
+
// while using the shared REDIS_CLIENT for XGROUP CREATE, XACK, and other non-blocking ops.
|
|
344
|
+
@Injectable()
|
|
345
|
+
export class EventConsumer extends RedisStreamConsumer {
|
|
346
|
+
// Just extend RedisStreamConsumer — connection isolation is built in
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
**Connection budget:** 1 shared `REDIS_CLIENT` + 1 dedicated connection per consumer.
|
|
351
|
+
For a service with 3 consumers, that's 4 total Redis connections.
|
|
352
|
+
|
|
353
|
+
### Module Exports — Only Export What You Provide
|
|
354
|
+
|
|
355
|
+
A module can only export providers it declares or imports. Exporting a class that isn't a provider in the module causes a runtime error.
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
// ❌ WRONG — RedisStreamPublisher not declared as provider
|
|
359
|
+
@Module({
|
|
360
|
+
providers: [EventStreamService],
|
|
361
|
+
exports: [EventStreamService, RedisStreamPublisher], // 🔥 RedisStreamPublisher not provided
|
|
362
|
+
})
|
|
363
|
+
export class EventBusModule {}
|
|
364
|
+
|
|
365
|
+
// ✅ CORRECT — only export what's in providers (or re-exported modules)
|
|
366
|
+
@Module({
|
|
367
|
+
providers: [EventStreamService],
|
|
368
|
+
exports: [EventStreamService],
|
|
369
|
+
})
|
|
370
|
+
export class EventBusModule {}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
## Feature Flags
|
|
374
|
+
|
|
375
|
+
Uses `@FeatureFlag()` decorator with Unleash. Three categories:
|
|
376
|
+
|
|
377
|
+
| Category | Naming | Lifetime | Default fallback |
|
|
378
|
+
|---|---|---|---|
|
|
379
|
+
| **Release** | `release-{module}-{feature}` | Remove within 30 days of full rollout | `throw` (Forbidden) |
|
|
380
|
+
| **Kill switch** | `kill-{module}-{feature}` | Permanent — stays in code | `throw` (Forbidden) |
|
|
381
|
+
| **Experiment** | `experiment-{module}-{feature}` | Remove within 90 days | `default` (control variant) |
|
|
382
|
+
|
|
383
|
+
**Fallback strategies** when a flag is disabled:
|
|
384
|
+
|
|
385
|
+
| Strategy | Behavior |
|
|
386
|
+
|---|---|
|
|
387
|
+
| `throw` (default) | Returns `Result.forbidden('Feature disabled: {flag}')` |
|
|
388
|
+
| `skip` | Returns `Result.success(undefined)` — for optional features |
|
|
389
|
+
| `default` | Returns `Result.success(defaultValue)` — for experiments |
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
@FeatureFlag('release-billing-invoices') // throw if disabled
|
|
393
|
+
@FeatureFlag('kill-payments-processing') // throw if disabled
|
|
394
|
+
@FeatureFlag('experiment-scoring-v2', { fallback: 'default', defaultValue: oldResult })
|
|
395
|
+
@FeatureFlag('release-notifications-email', { fallback: 'skip' }) // silently skip
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
**Graceful degradation:** If `UNLEASH_URL` is not set, all flags pass (features enabled by default). Local dev and tests work without Unleash.
|
|
399
|
+
|
|
400
|
+
## NEVER
|
|
401
|
+
|
|
402
|
+
- **NEVER** inject services or repositories into controllers — dispatch to the bus only
|
|
403
|
+
- **NEVER** put business logic in controllers
|
|
404
|
+
- **NEVER** put validation logic in handlers — use `@Validate` + `.validator.ts`
|
|
405
|
+
- **NEVER** use Joi, Yup, or other validation libraries — class-validator for DTOs, Zod for commands
|
|
406
|
+
- **NEVER** create a `.validator.ts` file without `@Validate(XxxValidator)` on the command class — it's dead code
|
|
407
|
+
- **NEVER** throw `HttpException` from handlers — return `Result<T>`
|
|
408
|
+
- **NEVER** use `Result.validationError()` in handlers
|
|
409
|
+
- **NEVER** ignore Result values from nested commands
|
|
410
|
+
- **NEVER** call `.unwrap()` inside handlers
|
|
411
|
+
- **NEVER** manually manage transactions — use `getTransactionalRepo()`
|
|
412
|
+
- **NEVER** share Redis connection for blocking XREADGROUP reads
|
|
413
|
+
- **NEVER** start blocking poll loops in `onModuleInit()` — use `onApplicationBootstrap()`
|
|
414
|
+
- **NEVER** publish events before the transaction commits
|
|
415
|
+
- **NEVER** silently drop failed stream events — retry with backoff or dead-letter
|
|
416
|
+
- **NEVER** use `NestJS EventEmitter` for inter-module events
|
|
417
|
+
- **NEVER** use global singletons for shared business state across modules — use Redis or the database
|
|
418
|
+
- **NEVER** import `.forRoot()` modules in feature modules
|
|
419
|
+
- **NEVER** use feature flags on infrastructure code (migrations, middleware, module config) — use env vars
|
|
420
|
+
- **NEVER** nest multiple `@FeatureFlag` decorators on one handler — one handler, one flag
|
|
421
|
+
- **NEVER** use flags as permanent configuration — if always needs on/off, use env vars
|