@3lineas/grooveskills 1.5.0 → 1.6.0
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/README.md +7 -0
- package/dist/{chunk-PLMYUKGW.js → chunk-HUG3MG3P.js} +131 -25
- package/dist/chunk-HUG3MG3P.js.map +1 -0
- package/dist/cli.js +2 -2
- package/dist/main.js +1 -1
- package/package.json +1 -1
- package/skills/auth-rbac-policies/SKILL.md +100 -0
- package/skills/auth-rbac-policies/references/auth-adapters-examples.md +99 -0
- package/skills/caching-rate-limiting/SKILL.md +109 -0
- package/skills/caching-rate-limiting/references/cache-rate-limit-examples.md +129 -0
- package/skills/clean-architecture-ddd/SKILL.md +1 -0
- package/skills/controller-orchestration-queues/SKILL.md +109 -0
- package/skills/controller-orchestration-queues/references/controller-queue-examples.md +115 -0
- package/skills/env-feature-flags/SKILL.md +94 -0
- package/skills/env-feature-flags/references/env-feature-flags-examples.md +93 -0
- package/skills/external-service-adapters/SKILL.md +148 -0
- package/skills/external-service-adapters/references/email-and-payment-examples.md +198 -0
- package/skills/storage-file-adapters/SKILL.md +125 -0
- package/skills/storage-file-adapters/references/storage-adapters-examples.md +164 -0
- package/skills/webhook-idempotency/SKILL.md +104 -0
- package/skills/webhook-idempotency/references/webhook-idempotency-examples.md +112 -0
- package/dist/chunk-PLMYUKGW.js.map +0 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: auth-rbac-policies
|
|
3
|
+
description: Standardized authentication adapter architecture and pure domain authorization policies (RBAC/ABAC). Replaces coupling to auth vendors (Supabase Auth, Clerk, Auth0, Lucia, JWT) with an AuthenticatedActor domain contract and isolated, pure boolean policy functions. TRIGGER when implementing login, session extraction, role-based access control, resource ownership checks, permission gates, or user token verification.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Authentication Adapters & Pure Authorization Policies (RBAC/ABAC)
|
|
7
|
+
|
|
8
|
+
Opinionated architectural standard decoupling authentication identity extraction (via vendor adapters) from authorization logic (via pure domain policy functions).
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. Architectural Distinction: AuthN vs. AuthZ
|
|
13
|
+
|
|
14
|
+
| Dimension | Authentication (AuthN) | Authorization (AuthZ) |
|
|
15
|
+
| :--- | :--- | :--- |
|
|
16
|
+
| **Question** | *Who is calling the system?* | *Is this actor permitted to perform this action on this resource?* |
|
|
17
|
+
| **Layer** | Infrastructure Adapter (`src/infrastructure/adapters/auth/`) | Pure Domain Policy (`src/domains/<domain>/policies/`) |
|
|
18
|
+
| **Mechanism** | JWT verify, Clerk/Supabase session token parser | Pure boolean logic: `(actor, resource) => boolean` |
|
|
19
|
+
| **Side Effects** | I/O, network tokens, crypto signature checks | Zero I/O, deterministic, 100% testable without mocks |
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 2. Standardized AuthenticatedActor Contract
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// src/domains/auth/authenticated-actor.ts
|
|
27
|
+
export interface AuthenticatedActor {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly role: "admin" | "member" | "viewer" | "guest";
|
|
30
|
+
readonly permissions: readonly string[];
|
|
31
|
+
readonly organizationId?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AuthAdapterPort {
|
|
35
|
+
authenticateBearerToken(headerValue?: string): Promise<AuthenticatedActor>;
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 3. Pure Authorization Policies (Zero Side Effects)
|
|
42
|
+
|
|
43
|
+
Policies must be standalone pure functions. Never instantiate an external database or call network APIs inside a policy.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// src/domains/documents/policies/document.policy.ts
|
|
47
|
+
import type { AuthenticatedActor } from "@/domains/auth/authenticated-actor";
|
|
48
|
+
|
|
49
|
+
export interface DocumentResource {
|
|
50
|
+
id: string;
|
|
51
|
+
authorId: string;
|
|
52
|
+
organizationId: string;
|
|
53
|
+
isPublished: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const DocumentPolicy = {
|
|
57
|
+
canView(actor: AuthenticatedActor, doc: DocumentResource): boolean {
|
|
58
|
+
if (actor.role === "admin") return true;
|
|
59
|
+
if (doc.isPublished && actor.organizationId === doc.organizationId) return true;
|
|
60
|
+
return actor.id === doc.authorId;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
canEdit(actor: AuthenticatedActor, doc: DocumentResource): boolean {
|
|
64
|
+
if (actor.role === "admin") return true;
|
|
65
|
+
return actor.id === doc.authorId;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
canDelete(actor: AuthenticatedActor, doc: DocumentResource): boolean {
|
|
69
|
+
return actor.role === "admin" || actor.id === doc.authorId;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 4. Enforcement in Actions or Route Middleware
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { AppError } from "@/errors/app-error";
|
|
80
|
+
import { DocumentPolicy, type DocumentResource } from "@/domains/documents/policies/document.policy";
|
|
81
|
+
import type { AuthenticatedActor } from "@/domains/auth/authenticated-actor";
|
|
82
|
+
|
|
83
|
+
export function authorizeOrThrow(actor: AuthenticatedActor, resource: DocumentResource): void {
|
|
84
|
+
const allowed = DocumentPolicy.canEdit(actor, resource);
|
|
85
|
+
if (!allowed) {
|
|
86
|
+
throw new AppError({
|
|
87
|
+
message: "No tienes permiso para modificar este documento.",
|
|
88
|
+
statusCode: 403,
|
|
89
|
+
context: { actorId: actor.id, docId: resource.id },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 5. Detailed Adapters Reference
|
|
98
|
+
|
|
99
|
+
For real implementations of Supabase Auth, Clerk, Custom JWT, and In-Memory testing actors, see:
|
|
100
|
+
- `references/auth-adapters-examples.md`
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Implementation Reference: Auth Adapters & Policies
|
|
2
|
+
|
|
3
|
+
Real adapter implementations (Clerk, Supabase, JWT) converting provider-specific tokens into `AuthenticatedActor`.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Clerk Authentication Adapter (`@clerk/backend`)
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// src/infrastructure/adapters/auth/clerk-auth.adapter.ts
|
|
11
|
+
import { verifyToken } from "@clerk/backend";
|
|
12
|
+
import type { AuthAdapterPort, AuthenticatedActor } from "@/domains/auth/authenticated-actor";
|
|
13
|
+
import { AppError } from "@/errors/app-error";
|
|
14
|
+
|
|
15
|
+
export class ClerkAuthAdapter implements AuthAdapterPort {
|
|
16
|
+
constructor(private jwtKey: string) {}
|
|
17
|
+
|
|
18
|
+
async authenticateBearerToken(headerValue?: string): Promise<AuthenticatedActor> {
|
|
19
|
+
const token = headerValue?.replace(/^Bearer\s+/i, "");
|
|
20
|
+
if (!token) {
|
|
21
|
+
throw new AppError({ message: "Token de autorización requerido.", statusCode: 401 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const payload = await verifyToken(token, { jwtKey: this.jwtKey });
|
|
26
|
+
const role = (payload.metadata as { role?: "admin" | "member" })?.role ?? "member";
|
|
27
|
+
return {
|
|
28
|
+
id: payload.sub,
|
|
29
|
+
role,
|
|
30
|
+
permissions: (payload.permissions as string[]) ?? [],
|
|
31
|
+
organizationId: payload.org_id as string | undefined,
|
|
32
|
+
};
|
|
33
|
+
} catch (err) {
|
|
34
|
+
throw new AppError({ message: "Token de autorización inválido o expirado.", statusCode: 401, cause: err });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 2. Supabase Auth Adapter (`@supabase/supabase-js`)
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// src/infrastructure/adapters/auth/supabase-auth.adapter.ts
|
|
46
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
47
|
+
import type { AuthAdapterPort, AuthenticatedActor } from "@/domains/auth/authenticated-actor";
|
|
48
|
+
import { AppError } from "@/errors/app-error";
|
|
49
|
+
|
|
50
|
+
export class SupabaseAuthAdapter implements AuthAdapterPort {
|
|
51
|
+
constructor(private supabase: SupabaseClient) {}
|
|
52
|
+
|
|
53
|
+
async authenticateBearerToken(headerValue?: string): Promise<AuthenticatedActor> {
|
|
54
|
+
const token = headerValue?.replace(/^Bearer\s+/i, "");
|
|
55
|
+
if (!token) {
|
|
56
|
+
throw new AppError({ message: "Sesión requerida.", statusCode: 401 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { data: { user }, error } = await this.supabase.auth.getUser(token);
|
|
60
|
+
if (error || !user) {
|
|
61
|
+
throw new AppError({ message: "Sesión no válida o expirada.", statusCode: 401, cause: error });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const appRole = (user.app_metadata?.role as "admin" | "member") ?? "member";
|
|
65
|
+
return {
|
|
66
|
+
id: user.id,
|
|
67
|
+
role: appRole,
|
|
68
|
+
permissions: (user.app_metadata?.permissions as string[]) ?? [],
|
|
69
|
+
organizationId: user.app_metadata?.org_id as string | undefined,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 3. In-Memory Mock Adapter (Deterministic Testing)
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// src/infrastructure/adapters/auth/memory-auth.adapter.ts
|
|
81
|
+
import type { AuthAdapterPort, AuthenticatedActor } from "@/domains/auth/authenticated-actor";
|
|
82
|
+
import { AppError } from "@/errors/app-error";
|
|
83
|
+
|
|
84
|
+
export class MemoryAuthAdapter implements AuthAdapterPort {
|
|
85
|
+
private users = new Map<string, AuthenticatedActor>();
|
|
86
|
+
|
|
87
|
+
seedUser(token: string, actor: AuthenticatedActor): void {
|
|
88
|
+
this.users.set(token, actor);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async authenticateBearerToken(headerValue?: string): Promise<AuthenticatedActor> {
|
|
92
|
+
const token = headerValue?.replace(/^Bearer\s+/i, "");
|
|
93
|
+
if (!token) throw new AppError({ message: "Falta token.", statusCode: 401 });
|
|
94
|
+
const actor = this.users.get(token);
|
|
95
|
+
if (!actor) throw new AppError({ message: "Token inválido.", statusCode: 401 });
|
|
96
|
+
return actor;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: caching-rate-limiting
|
|
3
|
+
description: Architectural standards for Cache-Aside wrappers (cache.remember pattern) and interchangeable Rate Limiter adapters (Redis, Cloudflare KV, In-Memory). Prevents leaking vendor cache drivers into business logic and guarantees clean sliding-window or token-bucket rate limiting. TRIGGER when optimizing database query performance, adding cache layers, implementing API throttling, DDoS prevention, or per-user rate limiting.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cache-Aside Wrapper & Rate Limiter Adapters
|
|
7
|
+
|
|
8
|
+
Opinionated architectural pattern for performance caching via the **Cache-Aside (`cache.remember`)** wrapper and decoupled **Rate Limiter** adapters.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. Core Architectural Concepts
|
|
13
|
+
|
|
14
|
+
1. **Cache-Aside Pattern (`cache.remember`)**:
|
|
15
|
+
- Instead of manual `get`, check null, query DB, and `set`, actions call `cache.remember(key, ttl, fetcher)`.
|
|
16
|
+
- Caching logic stays declarative, resilient, and decoupled from underlying stores.
|
|
17
|
+
2. **Interchangeable Rate Limiting Port**:
|
|
18
|
+
- Throttling (sliding window / token bucket) is encapsulated behind `RateLimiterPort`.
|
|
19
|
+
- HTTP route middleware and actions check permissions without knowing whether Redis, Cloudflare KV, or an In-Memory store enforces limits.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 2. Directory Layout
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
src/
|
|
27
|
+
├── domains/
|
|
28
|
+
│ ├── cache/
|
|
29
|
+
│ │ ├── cache-store.port.ts # Raw key-value cache contract (<40 lines)
|
|
30
|
+
│ │ └── cache.wrapper.ts # Cache-aside remember helper (<60 lines)
|
|
31
|
+
│ └── rate-limit/
|
|
32
|
+
│ └── rate-limiter.port.ts # Rate limiting contract (<40 lines)
|
|
33
|
+
└── infrastructure/
|
|
34
|
+
└── adapters/
|
|
35
|
+
├── cache/
|
|
36
|
+
│ ├── redis-cache.adapter.ts # Redis cache implementation (<70 lines)
|
|
37
|
+
│ ├── cloudflare-kv.adapter.ts # Cloudflare KV cache (<70 lines)
|
|
38
|
+
│ └── memory-cache.adapter.ts # In-memory testing cache (<60 lines)
|
|
39
|
+
└── rate-limit/
|
|
40
|
+
├── redis-rate-limiter.adapter.ts # Redis sliding window (<80 lines)
|
|
41
|
+
└── memory-rate-limiter.adapter.ts # In-memory rate limiter (<60 lines)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 3. Cache-Aside Wrapper (`src/domains/cache/cache.wrapper.ts`)
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import type { CacheStorePort } from "./cache-store.port";
|
|
50
|
+
|
|
51
|
+
export class CacheWrapper {
|
|
52
|
+
constructor(private store: CacheStorePort) {}
|
|
53
|
+
|
|
54
|
+
async remember<T>(key: string, ttlSeconds: number, fetcher: () => Promise<T>): Promise<T> {
|
|
55
|
+
const cached = await this.store.get<T>(key);
|
|
56
|
+
if (cached !== null && cached !== undefined) {
|
|
57
|
+
return cached;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const fresh = await fetcher();
|
|
61
|
+
await this.store.set(key, fresh, ttlSeconds);
|
|
62
|
+
return fresh;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async forget(key: string): Promise<void> {
|
|
66
|
+
await this.store.delete(key);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 4. Rate Limiter Port & Enforcement
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// src/domains/rate-limit/rate-limiter.port.ts
|
|
77
|
+
export interface RateLimitResult {
|
|
78
|
+
success: boolean;
|
|
79
|
+
limit: number;
|
|
80
|
+
remaining: number;
|
|
81
|
+
resetSeconds: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface RateLimiterPort {
|
|
85
|
+
limit(identifier: string): Promise<RateLimitResult>;
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Rate Limiter Middleware Example
|
|
90
|
+
```ts
|
|
91
|
+
export async function rateLimitMiddleware(c: Context, limiter: RateLimiterPort) {
|
|
92
|
+
const ip = c.req.header("cf-connecting-ip") ?? c.req.header("x-forwarded-for") ?? "anonymous";
|
|
93
|
+
const check = await limiter.limit(`ip:${ip}`);
|
|
94
|
+
|
|
95
|
+
c.header("X-RateLimit-Limit", String(check.limit));
|
|
96
|
+
c.header("X-RateLimit-Remaining", String(check.remaining));
|
|
97
|
+
|
|
98
|
+
if (!check.success) {
|
|
99
|
+
throw new AppError({ message: "Has excedido el límite de solicitudes. Intenta más tarde.", statusCode: 429 });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 5. Detailed Adapters Reference
|
|
107
|
+
|
|
108
|
+
For real implementations of Redis cache, Cloudflare KV, and Redis sliding-window rate limiters, consult:
|
|
109
|
+
- `references/cache-rate-limit-examples.md`
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Implementation Reference: Cache & Rate Limiting Adapters
|
|
2
|
+
|
|
3
|
+
Production implementations of Redis cache, Cloudflare KV, and sliding-window rate limiters.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Redis Cache Adapter (`ioredis`)
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// src/infrastructure/adapters/cache/redis-cache.adapter.ts
|
|
11
|
+
import type { Redis } from "ioredis";
|
|
12
|
+
import type { CacheStorePort } from "@/domains/cache/cache-store.port";
|
|
13
|
+
|
|
14
|
+
export class RedisCacheAdapter implements CacheStorePort {
|
|
15
|
+
constructor(private redis: Redis) {}
|
|
16
|
+
|
|
17
|
+
async get<T>(key: string): Promise<T | null> {
|
|
18
|
+
const raw = await this.redis.get(key);
|
|
19
|
+
if (!raw) return null;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(raw) as T;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
|
|
28
|
+
await this.redis.set(key, JSON.stringify(value), "EX", ttlSeconds);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async delete(key: string): Promise<void> {
|
|
32
|
+
await this.redis.del(key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 2. Cloudflare Workers KV Cache Adapter
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// src/infrastructure/adapters/cache/cloudflare-kv.adapter.ts
|
|
43
|
+
import type { CacheStorePort } from "@/domains/cache/cache-store.port";
|
|
44
|
+
|
|
45
|
+
export class CloudflareKvAdapter implements CacheStorePort {
|
|
46
|
+
constructor(private kv: KVNamespace) {}
|
|
47
|
+
|
|
48
|
+
async get<T>(key: string): Promise<T | null> {
|
|
49
|
+
return this.kv.get<T>(key, "json");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
|
|
53
|
+
await this.kv.put(key, JSON.stringify(value), { expirationTtl: Math.max(ttlSeconds, 60) });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async delete(key: string): Promise<void> {
|
|
57
|
+
await this.kv.delete(key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 3. Redis Sliding Window Rate Limiter Adapter
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// src/infrastructure/adapters/rate-limit/redis-rate-limiter.adapter.ts
|
|
68
|
+
import type { Redis } from "ioredis";
|
|
69
|
+
import type { RateLimiterPort, RateLimitResult } from "@/domains/rate-limit/rate-limiter.port";
|
|
70
|
+
|
|
71
|
+
export class RedisSlidingWindowRateLimiter implements RateLimiterPort {
|
|
72
|
+
constructor(
|
|
73
|
+
private redis: Redis,
|
|
74
|
+
private maxRequests: number = 60,
|
|
75
|
+
private windowSeconds: number = 60
|
|
76
|
+
) {}
|
|
77
|
+
|
|
78
|
+
async limit(identifier: string): Promise<RateLimitResult> {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const windowStart = now - this.windowSeconds * 1000;
|
|
81
|
+
const key = `ratelimit:${identifier}`;
|
|
82
|
+
|
|
83
|
+
const multi = this.redis.multi();
|
|
84
|
+
multi.zremrangebyscore(key, 0, windowStart);
|
|
85
|
+
multi.zadd(key, now, `${now}-${Math.random()}`);
|
|
86
|
+
multi.zcard(key);
|
|
87
|
+
multi.expire(key, this.windowSeconds);
|
|
88
|
+
|
|
89
|
+
const results = await multi.exec();
|
|
90
|
+
const count = (results?.[2]?.[1] as number) ?? 1;
|
|
91
|
+
|
|
92
|
+
const remaining = Math.max(0, this.maxRequests - count);
|
|
93
|
+
const success = count <= this.maxRequests;
|
|
94
|
+
|
|
95
|
+
return { success, limit: this.maxRequests, remaining, resetSeconds: this.windowSeconds };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## 4. In-Memory Testing Cache Adapter
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
// src/infrastructure/adapters/cache/memory-cache.adapter.ts
|
|
106
|
+
import type { CacheStorePort } from "@/domains/cache/cache-store.port";
|
|
107
|
+
|
|
108
|
+
export class MemoryCacheAdapter implements CacheStorePort {
|
|
109
|
+
private store = new Map<string, { value: unknown; expiresAt: number }>();
|
|
110
|
+
|
|
111
|
+
async get<T>(key: string): Promise<T | null> {
|
|
112
|
+
const item = this.store.get(key);
|
|
113
|
+
if (!item) return null;
|
|
114
|
+
if (Date.now() > item.expiresAt) {
|
|
115
|
+
this.store.delete(key);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return item.value as T;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
|
|
122
|
+
this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async delete(key: string): Promise<void> {
|
|
126
|
+
this.store.delete(key);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
@@ -101,3 +101,4 @@ export async function chargeSubscriptionAction(
|
|
|
101
101
|
- **`best-practices`**: Universal clean code rules and guard clauses.
|
|
102
102
|
- **`error-handling-logging`**: Structured `AppError` and `ActionResponse`.
|
|
103
103
|
- **`valibot-validation`**: Domain input/output validation.
|
|
104
|
+
- **`external-service-adapters`**: Standardized domain ports, vendor SDK wrappers, and adapters for third-party services.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: controller-orchestration-queues
|
|
3
|
+
description: Strict orchestration standards for Controllers, atomic Actions, database Transactions, and background Queue Adapters (Cloudflare Queues, BullMQ, AWS SQS, In-Memory). Mandates that Actions do only ONE atomic thing, while Controllers sequence multiple actions, manage transactions, or dispatch async jobs. Enforces Queue Workers as slim controllers. TRIGGER when orchestrating multi-step workflows, handling DB transactions across actions, dispatching background tasks, or designing queue consumers.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Controller Action Orchestration, Transactions & Queue Adapters
|
|
7
|
+
|
|
8
|
+
Architectural standard establishing strict separation of concerns between **Atomic Actions** and **Orchestration Controllers / Queue Workers**.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. The Core Architectural Law
|
|
13
|
+
|
|
14
|
+
1. **Actions Do Exactly ONE Atomic Thing**:
|
|
15
|
+
- Pure single responsibility. Example: `createOrderAction`, `chargeCardAction`, `sendOrderEmailAction`.
|
|
16
|
+
- Actions NEVER orchestrate other sibling actions or dispatch unrelated background jobs directly.
|
|
17
|
+
2. **Controllers are Orchestrators**:
|
|
18
|
+
- HTTP route handlers and RPC controllers validate inputs, sequence actions, wrap transactional DB boundaries (`withTransaction`), and dispatch background jobs via `QueuePort`.
|
|
19
|
+
3. **Queue Workers are Slim Controllers**:
|
|
20
|
+
- A queue worker message handler behaves like an HTTP controller: parses payload and delegates to atomic actions.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 2. Directory Layout
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
src/
|
|
28
|
+
├── actions/
|
|
29
|
+
│ ├── orders/
|
|
30
|
+
│ │ ├── create-order.action.ts # Atomic insert order (<70 lines)
|
|
31
|
+
│ │ └── update-order-status.action.ts # Atomic update status (<60 lines)
|
|
32
|
+
│ └── billing/
|
|
33
|
+
│ └── charge-card.action.ts # Atomic payment charge (<70 lines)
|
|
34
|
+
├── controllers/
|
|
35
|
+
│ └── orders/
|
|
36
|
+
│ └── checkout.controller.ts # Orchestrates actions + Queue (<90 lines)
|
|
37
|
+
├── domains/
|
|
38
|
+
│ └── queue/
|
|
39
|
+
│ ├── queue.port.ts # Domain interface for dispatching (<40 lines)
|
|
40
|
+
│ └── queue-message.types.ts # Strongly typed queue messages (<50 lines)
|
|
41
|
+
└── infrastructure/
|
|
42
|
+
├── adapters/
|
|
43
|
+
│ └── queue/
|
|
44
|
+
│ ├── cloudflare-queues.adapter.ts # Cloudflare Queues adapter (<80 lines)
|
|
45
|
+
│ ├── bullmq-queues.adapter.ts # Redis / BullMQ adapter (<80 lines)
|
|
46
|
+
│ ├── sqs-queues.adapter.ts # AWS SQS adapter (<80 lines)
|
|
47
|
+
│ └── memory-queues.adapter.ts # In-memory testing queue (<60 lines)
|
|
48
|
+
└── workers/
|
|
49
|
+
└── order-events.worker.ts # Slim worker invoking actions (<80 lines)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 3. Domain Queue Contract (`src/domains/queue/queue.port.ts`)
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
export interface QueueJob<TData = unknown> {
|
|
58
|
+
id?: string;
|
|
59
|
+
name: string;
|
|
60
|
+
data: TData;
|
|
61
|
+
delaySeconds?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface QueuePort {
|
|
65
|
+
dispatch<T>(job: QueueJob<T>): Promise<{ jobId: string }>;
|
|
66
|
+
dispatchBatch<T>(jobs: QueueJob<T>[]): Promise<{ count: number }>;
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 4. Orchestration Controller Pattern
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// src/controllers/orders/checkout.controller.ts
|
|
76
|
+
import type { Context } from "hono";
|
|
77
|
+
import type { QueuePort } from "@/domains/queue/queue.port";
|
|
78
|
+
import { createOrderAction } from "@/actions/orders/create-order.action";
|
|
79
|
+
import { chargeCardAction } from "@/actions/billing/charge-card.action";
|
|
80
|
+
import { db, withTransaction } from "@/infrastructure/db/connection";
|
|
81
|
+
|
|
82
|
+
export async function checkoutController(c: Context, queue: QueuePort) {
|
|
83
|
+
const payload = c.get("validatedJson");
|
|
84
|
+
const actor = c.get("actor");
|
|
85
|
+
|
|
86
|
+
// Step 1: Charge card (external side-effect, kept outside DB transaction)
|
|
87
|
+
const charge = await chargeCardAction(payload.paymentMethodId, payload.amount);
|
|
88
|
+
|
|
89
|
+
// Step 2: Atomic DB Transaction for local entity creation
|
|
90
|
+
const order = await withTransaction(db, async (tx) => {
|
|
91
|
+
return createOrderAction(tx, { actorId: actor.id, amount: payload.amount, chargeId: charge.id });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Step 3: Dispatch async post-processing via Queue
|
|
95
|
+
await queue.dispatch({
|
|
96
|
+
name: "orders.post-checkout",
|
|
97
|
+
data: { orderId: order.id, customerEmail: actor.email },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return c.json({ orderId: order.id, status: "confirmed" }, 201);
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 5. Detailed Adapters & Workers Reference
|
|
107
|
+
|
|
108
|
+
For real implementations of Cloudflare Queues, BullMQ, AWS SQS, and In-Memory Queue adapters, consult:
|
|
109
|
+
- `references/controller-queue-examples.md`
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Implementation Reference: Controller Orchestration & Queue Adapters
|
|
2
|
+
|
|
3
|
+
Real-world queue adapters (Cloudflare Queues, BullMQ, AWS SQS, In-Memory) and Slim Worker controllers.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Cloudflare Queues Adapter
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// src/infrastructure/adapters/queue/cloudflare-queues.adapter.ts
|
|
11
|
+
import type { QueuePort, QueueJob } from "@/domains/queue/queue.port";
|
|
12
|
+
import { AppError } from "@/errors/app-error";
|
|
13
|
+
|
|
14
|
+
export class CloudflareQueueAdapter implements QueuePort {
|
|
15
|
+
constructor(private queue: Queue) {}
|
|
16
|
+
|
|
17
|
+
async dispatch<T>(job: QueueJob<T>): Promise<{ jobId: string }> {
|
|
18
|
+
try {
|
|
19
|
+
await this.queue.send(job, { delaySeconds: job.delaySeconds });
|
|
20
|
+
return { jobId: job.id ?? crypto.randomUUID() };
|
|
21
|
+
} catch (err) {
|
|
22
|
+
throw new AppError({ message: "Error al encolar mensaje en Cloudflare Queues.", statusCode: 502, cause: err });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async dispatchBatch<T>(jobs: QueueJob<T>[]): Promise<{ count: number }> {
|
|
27
|
+
await this.queue.sendBatch(jobs.map((j) => ({ body: j, delaySeconds: j.delaySeconds })));
|
|
28
|
+
return { count: jobs.length };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 2. BullMQ (Redis) Adapter
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// src/infrastructure/adapters/queue/bullmq-queues.adapter.ts
|
|
39
|
+
import { Queue as BullQueue } from "bullmq";
|
|
40
|
+
import type { QueuePort, QueueJob } from "@/domains/queue/queue.port";
|
|
41
|
+
import { AppError } from "@/errors/app-error";
|
|
42
|
+
|
|
43
|
+
export class BullMQAdapter implements QueuePort {
|
|
44
|
+
constructor(private bullQueue: BullQueue) {}
|
|
45
|
+
|
|
46
|
+
async dispatch<T>(job: QueueJob<T>): Promise<{ jobId: string }> {
|
|
47
|
+
try {
|
|
48
|
+
const bJob = await this.bullQueue.add(job.name, job.data, {
|
|
49
|
+
jobId: job.id,
|
|
50
|
+
delay: job.delaySeconds ? job.delaySeconds * 1000 : undefined,
|
|
51
|
+
});
|
|
52
|
+
return { jobId: String(bJob.id) };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
throw new AppError({ message: "Error al enviar trabajo a BullMQ.", statusCode: 502, cause: err });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async dispatchBatch<T>(jobs: QueueJob<T>[]): Promise<{ count: number }> {
|
|
59
|
+
await this.bullQueue.addBulk(
|
|
60
|
+
jobs.map((j) => ({
|
|
61
|
+
name: j.name,
|
|
62
|
+
data: j.data,
|
|
63
|
+
opts: { jobId: j.id, delay: j.delaySeconds ? j.delaySeconds * 1000 : undefined },
|
|
64
|
+
}))
|
|
65
|
+
);
|
|
66
|
+
return { count: jobs.length };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 3. In-Memory Queue Adapter (Testing Fake)
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// src/infrastructure/adapters/queue/memory-queues.adapter.ts
|
|
77
|
+
import type { QueuePort, QueueJob } from "@/domains/queue/queue.port";
|
|
78
|
+
|
|
79
|
+
export class MemoryQueueAdapter implements QueuePort {
|
|
80
|
+
public jobs: QueueJob[] = [];
|
|
81
|
+
|
|
82
|
+
async dispatch<T>(job: QueueJob<T>): Promise<{ jobId: string }> {
|
|
83
|
+
const id = job.id ?? `job_${this.jobs.length + 1}`;
|
|
84
|
+
this.jobs.push({ ...job, id });
|
|
85
|
+
return { jobId: id };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async dispatchBatch<T>(jobs: QueueJob<T>[]): Promise<{ count: number }> {
|
|
89
|
+
for (const job of jobs) {
|
|
90
|
+
await this.dispatch(job);
|
|
91
|
+
}
|
|
92
|
+
return { count: jobs.length };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 4. Slim Queue Worker Controller Pattern
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
// src/infrastructure/workers/order-events.worker.ts
|
|
103
|
+
import { sendOrderEmailAction } from "@/actions/email/send-order-email.action";
|
|
104
|
+
import { syncCrmAction } from "@/actions/crm/sync-crm.action";
|
|
105
|
+
import { logger } from "@/infrastructure/logger/app-logger";
|
|
106
|
+
|
|
107
|
+
export async function handleOrderQueueMessage(messageBody: { name: string; data: { orderId: string; customerEmail: string } }) {
|
|
108
|
+
logger.info("Procesando evento de orden", { jobName: messageBody.name, orderId: messageBody.data.orderId });
|
|
109
|
+
|
|
110
|
+
if (messageBody.name === "orders.post-checkout") {
|
|
111
|
+
await sendOrderEmailAction(messageBody.data.customerEmail, messageBody.data.orderId);
|
|
112
|
+
await syncCrmAction(messageBody.data.orderId);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|