@3lineas/grooveskills 1.7.1 → 1.8.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 +3 -2
- package/dist/{chunk-SP3ESDM5.js → chunk-2VONHHPV.js} +80 -17
- package/dist/chunk-2VONHHPV.js.map +1 -0
- package/dist/cli.js +2 -2
- package/dist/main.js +1 -1
- package/package.json +1 -1
- package/skills/controller-orchestration-queues/SKILL.md +62 -63
- package/skills/controller-orchestration-queues/references/controller-queue-examples.md +66 -42
- package/skills/error-handling-logging/SKILL.md +64 -68
- package/skills/error-handling-logging/templates/app-error.template.md +10 -1
- package/skills/hono/SKILL.md +73 -66
- package/skills/hono/templates/router.template.md +18 -26
- package/skills/react-core/SKILL.md +45 -112
- package/skills/testing-vitest/SKILL.md +68 -61
- package/skills/testing-vitest/references/backend-d1-testing-pyramid.md +69 -0
- package/skills/typescript-clean/SKILL.md +40 -20
- package/skills/valibot-validation/SKILL.md +63 -20
- package/skills/workers-rpc-precision/SKILL.md +129 -0
- package/skills/workers-rpc-precision/references/python-worker-rpc-examples.md +98 -0
- package/dist/chunk-SP3ESDM5.js.map +0 -1
|
@@ -1,24 +1,34 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: controller-orchestration-queues
|
|
3
|
-
description: Strict orchestration standards for Controllers, atomic Actions,
|
|
3
|
+
description: Strict orchestration standards for Controllers, atomic Actions, Pipeline pattern, database Transactions (including Cloudflare D1 batching), and background Queue Adapters. 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
4
|
---
|
|
5
5
|
|
|
6
|
-
# Controller Action Orchestration, Transactions &
|
|
6
|
+
# Controller Action Orchestration, Pipelines, Transactions & Queues
|
|
7
7
|
|
|
8
|
-
Architectural standard establishing strict separation of concerns between **Atomic Actions** and **Orchestration Controllers / Queue Workers**.
|
|
8
|
+
Architectural standard establishing strict separation of concerns between **Atomic Actions**, **Declarative Pipelines**, **Transactions (SQL / D1)**, and **Orchestration Controllers / Queue Workers**.
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
12
|
## 1. The Core Architectural Law
|
|
13
13
|
|
|
14
14
|
1. **Actions Do Exactly ONE Atomic Thing**:
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
- Single responsibility. Example: `sanitizeSlugAction`, `ensureAvailableAction`, `persistOrderAction`.
|
|
16
|
+
- **An Action NEVER calls another Action directly.** No nested Action cascades.
|
|
17
|
+
- Signature: `(context: TContext) => Promise<TContext> | TContext`.
|
|
18
|
+
|
|
19
|
+
2. **Controllers & Pipelines are Orchestrators**:
|
|
20
|
+
- HTTP route handlers and RPC controllers validate inputs, sequence actions through Pipelines (`Pipeline.send(context).through([...actions])`), wrap transactions, and dispatch background jobs via `QueuePort`.
|
|
21
|
+
|
|
22
|
+
3. **Atomic Multi-Mutation Pipelines (`withTransaction` / `withD1Transaction`)**:
|
|
23
|
+
- In standard relational databases, wrap actions inside a database transaction handle (`tx`).
|
|
24
|
+
- In serverless SQLite / Cloudflare D1, wrap pipeline executions with `withD1Transaction`: actions run immediate reads via `tx.db.select()` and stage mutations via `tx.stage(stmt)` to guarantee all-or-nothing rollback using `db.batch()`.
|
|
25
|
+
|
|
26
|
+
4. **Queue Workers are Slim Controllers**:
|
|
20
27
|
- A queue worker message handler behaves like an HTTP controller: parses payload and delegates to atomic actions.
|
|
21
28
|
|
|
29
|
+
5. **Hard Size Limit of <200 Lines**:
|
|
30
|
+
- Controllers, actions, and pipeline steps must remain strictly below 200 lines (target: 20-60 lines).
|
|
31
|
+
|
|
22
32
|
---
|
|
23
33
|
|
|
24
34
|
## 2. Directory Layout
|
|
@@ -26,84 +36,73 @@ Architectural standard establishing strict separation of concerns between **Atom
|
|
|
26
36
|
```text
|
|
27
37
|
src/
|
|
28
38
|
├── actions/
|
|
29
|
-
│
|
|
30
|
-
│
|
|
31
|
-
│
|
|
32
|
-
│
|
|
33
|
-
│ └── charge-card.action.ts # Atomic payment charge (<70 lines)
|
|
39
|
+
│ └── orders/
|
|
40
|
+
│ ├── sanitize-order.action.ts # Atomic data transform (<50 lines)
|
|
41
|
+
│ ├── ensure-stock.action.ts # Atomic invariant check (<50 lines)
|
|
42
|
+
│ └── persist-order.action.ts # Atomic DB persistence (<60 lines)
|
|
34
43
|
├── controllers/
|
|
35
44
|
│ └── orders/
|
|
36
|
-
│ └── checkout.controller.ts
|
|
45
|
+
│ └── checkout.controller.ts # Pipeline & Queue Orchestration (<80 lines)
|
|
37
46
|
├── domains/
|
|
38
47
|
│ └── queue/
|
|
39
48
|
│ ├── queue.port.ts # Domain interface for dispatching (<40 lines)
|
|
40
49
|
│ └── queue-message.types.ts # Strongly typed queue messages (<50 lines)
|
|
41
50
|
└── infrastructure/
|
|
42
|
-
|
|
43
|
-
|
|
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)
|
|
51
|
+
└── adapters/
|
|
52
|
+
└── queue/ # Swappable Queue adapters (Cloudflare, BullMQ, Memory)
|
|
50
53
|
```
|
|
51
54
|
|
|
52
55
|
---
|
|
53
56
|
|
|
54
|
-
## 3.
|
|
57
|
+
## 3. Pipeline Orchestration Pattern
|
|
55
58
|
|
|
56
59
|
```ts
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
import type { Context } from "hono";
|
|
61
|
+
import { Pipeline } from "@/common/utils/pipeline";
|
|
62
|
+
import { sanitizeOrderAction } from "@/actions/orders/sanitize-order.action";
|
|
63
|
+
import { ensureStockAction } from "@/actions/orders/ensure-stock.action";
|
|
64
|
+
import { persistOrderAction } from "@/actions/orders/persist-order.action";
|
|
65
|
+
import type { CheckoutContext } from "@/types/orders/checkout-context.type";
|
|
66
|
+
|
|
67
|
+
export async function checkoutController(ctx: Context) {
|
|
68
|
+
const database = ctx.get("db");
|
|
69
|
+
const validatedPayload = ctx.req.valid("json");
|
|
70
|
+
|
|
71
|
+
const finalContext = await Pipeline.send<CheckoutContext>({
|
|
72
|
+
database,
|
|
73
|
+
input: validatedPayload,
|
|
74
|
+
})
|
|
75
|
+
.through([
|
|
76
|
+
sanitizeOrderAction,
|
|
77
|
+
ensureStockAction,
|
|
78
|
+
persistOrderAction,
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
return ctx.json({ success: true, data: finalContext.createdOrder }, 201);
|
|
67
82
|
}
|
|
68
83
|
```
|
|
69
84
|
|
|
70
85
|
---
|
|
71
86
|
|
|
72
|
-
## 4.
|
|
87
|
+
## 4. Cloudflare D1 Staged Batching (`withD1Transaction`)
|
|
88
|
+
|
|
89
|
+
For Cloudflare D1, wrap mutations in a staged batch to guarantee ACID rollback:
|
|
73
90
|
|
|
74
91
|
```ts
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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);
|
|
92
|
+
export async function stageOrderMutationAction(context: CheckoutContext): Promise<CheckoutContext> {
|
|
93
|
+
const insertStatement = context.database
|
|
94
|
+
.insert(ordersTable)
|
|
95
|
+
.values(context.newOrderRecord);
|
|
96
|
+
|
|
97
|
+
// Stage statement for atomic db.batch() execution
|
|
98
|
+
context.transaction?.stage(insertStatement);
|
|
99
|
+
return context;
|
|
101
100
|
}
|
|
102
101
|
```
|
|
103
102
|
|
|
104
103
|
---
|
|
105
104
|
|
|
106
|
-
## 5. Detailed Adapters &
|
|
105
|
+
## 5. Detailed Adapters & References
|
|
107
106
|
|
|
108
|
-
For real implementations of Cloudflare Queues, BullMQ, AWS SQS,
|
|
109
|
-
-
|
|
107
|
+
For real implementations of Cloudflare Queues, BullMQ, AWS SQS, In-Memory Queue adapters, and the full D1 Transaction runner, consult:
|
|
108
|
+
- `./references/controller-queue-examples.md`
|
|
@@ -1,13 +1,51 @@
|
|
|
1
|
-
# Implementation Reference: Controller Orchestration &
|
|
1
|
+
# Implementation Reference: Controller Orchestration, Queues & D1 Transactions
|
|
2
2
|
|
|
3
|
-
Real-world queue adapters (Cloudflare Queues, BullMQ, AWS SQS, In-Memory) and
|
|
3
|
+
Real-world queue adapters (Cloudflare Queues, BullMQ, AWS SQS, In-Memory) and atomic Cloudflare D1 transaction runner.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
## 1. Cloudflare
|
|
7
|
+
## 1. Cloudflare D1 Atomic Transaction Runner (`withD1Transaction`)
|
|
8
|
+
|
|
9
|
+
Cloudflare D1 runs SQLite at the edge, where standard multi-statement transactions are executed via `db.batch()`. The `withD1Transaction` pattern allows actions to stage statements and execute them atomically:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import type { DrizzleD1Database } from "drizzle-orm/d1";
|
|
13
|
+
import type { BatchItem } from "drizzle-orm/batch";
|
|
14
|
+
|
|
15
|
+
export interface D1TransactionContext<TSchema extends Record<string, unknown>> {
|
|
16
|
+
readonly db: DrizzleD1Database<TSchema>;
|
|
17
|
+
stage: (statement: BatchItem<"sqlite">) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function withD1Transaction<TSchema extends Record<string, unknown>, TResult>(
|
|
21
|
+
database: DrizzleD1Database<TSchema>,
|
|
22
|
+
callback: (transaction: D1TransactionContext<TSchema>) => Promise<TResult>
|
|
23
|
+
): Promise<TResult> {
|
|
24
|
+
const stagedStatements: BatchItem<"sqlite">[] = [];
|
|
25
|
+
|
|
26
|
+
const transactionContext: D1TransactionContext<TSchema> = {
|
|
27
|
+
db: database,
|
|
28
|
+
stage: (statement) => {
|
|
29
|
+
stagedStatements.push(statement);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const result = await callback(transactionContext);
|
|
34
|
+
|
|
35
|
+
if (stagedStatements.length > 0) {
|
|
36
|
+
// All staged mutations are executed in a single atomic batch
|
|
37
|
+
await database.batch(stagedStatements as [BatchItem<"sqlite">, ...BatchItem<"sqlite">[]]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 2. Cloudflare Queues Adapter
|
|
8
47
|
|
|
9
48
|
```ts
|
|
10
|
-
// src/infrastructure/adapters/queue/cloudflare-queues.adapter.ts
|
|
11
49
|
import type { QueuePort, QueueJob } from "@/domains/queue/queue.port";
|
|
12
50
|
import { AppError } from "@/errors/app-error";
|
|
13
51
|
|
|
@@ -18,13 +56,17 @@ export class CloudflareQueueAdapter implements QueuePort {
|
|
|
18
56
|
try {
|
|
19
57
|
await this.queue.send(job, { delaySeconds: job.delaySeconds });
|
|
20
58
|
return { jobId: job.id ?? crypto.randomUUID() };
|
|
21
|
-
} catch (
|
|
22
|
-
throw new AppError({
|
|
59
|
+
} catch (caughtError) {
|
|
60
|
+
throw new AppError({
|
|
61
|
+
message: "Error al encolar mensaje en Cloudflare Queues.",
|
|
62
|
+
statusCode: 502,
|
|
63
|
+
cause: caughtError,
|
|
64
|
+
});
|
|
23
65
|
}
|
|
24
66
|
}
|
|
25
67
|
|
|
26
68
|
async dispatchBatch<T>(jobs: QueueJob<T>[]): Promise<{ count: number }> {
|
|
27
|
-
await this.queue.sendBatch(jobs.map((
|
|
69
|
+
await this.queue.sendBatch(jobs.map((job) => ({ body: job, delaySeconds: job.delaySeconds })));
|
|
28
70
|
return { count: jobs.length };
|
|
29
71
|
}
|
|
30
72
|
}
|
|
@@ -32,10 +74,9 @@ export class CloudflareQueueAdapter implements QueuePort {
|
|
|
32
74
|
|
|
33
75
|
---
|
|
34
76
|
|
|
35
|
-
##
|
|
77
|
+
## 3. BullMQ (Redis) Adapter
|
|
36
78
|
|
|
37
79
|
```ts
|
|
38
|
-
// src/infrastructure/adapters/queue/bullmq-queues.adapter.ts
|
|
39
80
|
import { Queue as BullQueue } from "bullmq";
|
|
40
81
|
import type { QueuePort, QueueJob } from "@/domains/queue/queue.port";
|
|
41
82
|
import { AppError } from "@/errors/app-error";
|
|
@@ -45,22 +86,26 @@ export class BullMQAdapter implements QueuePort {
|
|
|
45
86
|
|
|
46
87
|
async dispatch<T>(job: QueueJob<T>): Promise<{ jobId: string }> {
|
|
47
88
|
try {
|
|
48
|
-
const
|
|
89
|
+
const queuedJob = await this.bullQueue.add(job.name, job.data, {
|
|
49
90
|
jobId: job.id,
|
|
50
91
|
delay: job.delaySeconds ? job.delaySeconds * 1000 : undefined,
|
|
51
92
|
});
|
|
52
|
-
return { jobId: String(
|
|
53
|
-
} catch (
|
|
54
|
-
throw new AppError({
|
|
93
|
+
return { jobId: String(queuedJob.id) };
|
|
94
|
+
} catch (caughtError) {
|
|
95
|
+
throw new AppError({
|
|
96
|
+
message: "Error al enviar trabajo a BullMQ.",
|
|
97
|
+
statusCode: 502,
|
|
98
|
+
cause: caughtError,
|
|
99
|
+
});
|
|
55
100
|
}
|
|
56
101
|
}
|
|
57
102
|
|
|
58
103
|
async dispatchBatch<T>(jobs: QueueJob<T>[]): Promise<{ count: number }> {
|
|
59
104
|
await this.bullQueue.addBulk(
|
|
60
|
-
jobs.map((
|
|
61
|
-
name:
|
|
62
|
-
data:
|
|
63
|
-
opts: { jobId:
|
|
105
|
+
jobs.map((job) => ({
|
|
106
|
+
name: job.name,
|
|
107
|
+
data: job.data,
|
|
108
|
+
opts: { jobId: job.id, delay: job.delaySeconds ? job.delaySeconds * 1000 : undefined },
|
|
64
109
|
}))
|
|
65
110
|
);
|
|
66
111
|
return { count: jobs.length };
|
|
@@ -70,19 +115,18 @@ export class BullMQAdapter implements QueuePort {
|
|
|
70
115
|
|
|
71
116
|
---
|
|
72
117
|
|
|
73
|
-
##
|
|
118
|
+
## 4. In-Memory Queue Adapter (Testing Fake)
|
|
74
119
|
|
|
75
120
|
```ts
|
|
76
|
-
// src/infrastructure/adapters/queue/memory-queues.adapter.ts
|
|
77
121
|
import type { QueuePort, QueueJob } from "@/domains/queue/queue.port";
|
|
78
122
|
|
|
79
123
|
export class MemoryQueueAdapter implements QueuePort {
|
|
80
124
|
public jobs: QueueJob[] = [];
|
|
81
125
|
|
|
82
126
|
async dispatch<T>(job: QueueJob<T>): Promise<{ jobId: string }> {
|
|
83
|
-
const
|
|
84
|
-
this.jobs.push({ ...job, id });
|
|
85
|
-
return { jobId
|
|
127
|
+
const jobId = job.id ?? `job_${this.jobs.length + 1}`;
|
|
128
|
+
this.jobs.push({ ...job, id: jobId });
|
|
129
|
+
return { jobId };
|
|
86
130
|
}
|
|
87
131
|
|
|
88
132
|
async dispatchBatch<T>(jobs: QueueJob<T>[]): Promise<{ count: number }> {
|
|
@@ -93,23 +137,3 @@ export class MemoryQueueAdapter implements QueuePort {
|
|
|
93
137
|
}
|
|
94
138
|
}
|
|
95
139
|
```
|
|
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
|
-
```
|
|
@@ -1,99 +1,95 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: error-handling-logging
|
|
3
|
-
description: Deterministic error handling with custom AppError, Result/ActionResponse contracts, and structured logging. TRIGGER when designing error boundaries, writing try/catch blocks, handling I/O or network failures, creating domain exceptions, or configuring application loggers.
|
|
3
|
+
description: Deterministic error handling with custom AppError, domain exception hierarchies, silent mode, Result/ActionResponse contracts, and structured logging. TRIGGER when designing error boundaries, writing try/catch blocks, handling I/O or network failures, creating domain exceptions, or configuring application loggers.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Error Handling & Structured Logging Standards
|
|
7
7
|
|
|
8
|
-
Enterprise-grade patterns for deterministic error handling,
|
|
8
|
+
Enterprise-grade patterns for deterministic error handling, typed domain exceptions, silent log mode for expected business flows, and safe API error responses.
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
12
|
## 1. Golden Rules (Non-negotiable)
|
|
13
13
|
|
|
14
|
-
1. **
|
|
15
|
-
-
|
|
16
|
-
-
|
|
14
|
+
1. **Hierarchy Based on `AppError`**:
|
|
15
|
+
- Every domain error or business exception **MUST** extend `AppError`.
|
|
16
|
+
- Never throw raw strings or generic untyped `Error("failed")` in domain actions or controllers.
|
|
17
17
|
|
|
18
|
-
2. **
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
- Log with `Logger.error(...)` and re-throw / return an `ActionResponse` failure.
|
|
22
|
-
- Empty catch blocks (`catch {}`) without explicit intent are forbidden.
|
|
18
|
+
2. **Atomic Domain Exceptions (1 Exception per File)**:
|
|
19
|
+
- Each custom exception resides in its own atomic file: `@/errors/{domain}/{name}.error.ts` (e.g. `slug-conflict.error.ts`, `user-not-found.error.ts`).
|
|
20
|
+
- Throw custom errors directly without boilerplate try/catch re-wrapping inside business logic.
|
|
23
21
|
|
|
24
|
-
3. **
|
|
25
|
-
-
|
|
26
|
-
-
|
|
22
|
+
3. **Auto-Logging with `silent: true` for Expected Flows**:
|
|
23
|
+
- `AppError` emits a warning via structured logger upon instantiation.
|
|
24
|
+
- For expected user conflicts (e.g. 409 duplicate slug, 404 not found, 400 validation error), set `silent: true` in the constructor to avoid polluting server error alerts.
|
|
27
25
|
|
|
28
|
-
4. **
|
|
29
|
-
-
|
|
26
|
+
4. **Universal Error Normalization in Catch Blocks**:
|
|
27
|
+
- Every top-level catch block or global error handler must normalize unknown or unexpected runtime errors into `AppError` subclasses (e.g. `InternalServerError`).
|
|
28
|
+
- All HTTP responses are formatted via standardized response serialization (`ActionResponse<never>`).
|
|
30
29
|
|
|
31
|
-
5. **
|
|
32
|
-
-
|
|
33
|
-
-
|
|
30
|
+
5. **Separate User-Safe Messages from Internal Diagnostics**:
|
|
31
|
+
- The user receives courteous, empathetic, and actionable feedback in the configured locale (governed by `ux-writing`).
|
|
32
|
+
- Internal logs receive complete diagnostic metadata (stack trace, payload params, correlation ID).
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
## 2. Standard Contracts
|
|
38
|
-
|
|
39
|
-
### A. The ActionResponse Contract
|
|
40
|
-
|
|
41
|
-
```ts
|
|
42
|
-
export type ActionResponse<TData = unknown, TError = string> =
|
|
43
|
-
| { success: true; data: TData; message?: string }
|
|
44
|
-
| { success: false; error: TError; message?: string; statusCode?: number };
|
|
34
|
+
6. **Hard Size Limit of <200 Lines**:
|
|
35
|
+
- Keep atomic exception classes under 40 lines and error handlers concise.
|
|
45
36
|
|
|
46
|
-
|
|
47
|
-
return { success: true, data, message };
|
|
48
|
-
}
|
|
37
|
+
---
|
|
49
38
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
39
|
+
## 2. Directory Layout
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
src/
|
|
43
|
+
├── errors/
|
|
44
|
+
│ ├── app-error.ts # Base AppError with auto-logging & silent flag
|
|
45
|
+
│ ├── common/
|
|
46
|
+
│ │ ├── internal-server.error.ts # 500 error for unhandled exceptions
|
|
47
|
+
│ │ └── validation.error.ts # 400 error for schema validation
|
|
48
|
+
│ └── {domain}/ # Domain-specific custom exceptions
|
|
49
|
+
│ ├── slug-conflict.error.ts # e.g., extends AppError (409, silent: true)
|
|
50
|
+
│ └── entity-not-found.error.ts # e.g., extends AppError (404, silent: true)
|
|
51
|
+
└── types/errors/
|
|
52
|
+
└── app-error-params.type.ts # AppError constructor options
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
---
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
## 3. Standard Contracts & Domain Exceptions
|
|
58
58
|
|
|
59
|
+
### A. Domain Custom Exception Example (`src/errors/stores/slug-conflict.error.ts`)
|
|
59
60
|
```ts
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
61
|
+
import { AppError } from "@/errors/app-error";
|
|
62
|
+
|
|
63
|
+
export class SlugConflictError extends AppError {
|
|
64
|
+
constructor(
|
|
65
|
+
slug: string,
|
|
66
|
+
message = `El enlace '${slug}' ya está registrado. Te sugerimos agregar una palabra o número adicional.`
|
|
67
|
+
) {
|
|
68
|
+
super({
|
|
69
|
+
message,
|
|
70
|
+
statusCode: 409,
|
|
71
|
+
code: "SLUG_ALREADY_IN_USE",
|
|
72
|
+
details: { slug },
|
|
73
|
+
silent: true, // Expected business conflict: suppress error log alert
|
|
74
|
+
});
|
|
75
|
+
this.name = "SlugConflictError";
|
|
71
76
|
}
|
|
72
77
|
}
|
|
73
78
|
```
|
|
74
79
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
## 3. Safe Try/Catch Pattern
|
|
78
|
-
|
|
80
|
+
### B. Universal Catch Normalization Pattern
|
|
79
81
|
```ts
|
|
80
|
-
import { Logger } from "@/utils/logger";
|
|
81
82
|
import { AppError } from "@/errors/app-error";
|
|
82
|
-
import {
|
|
83
|
-
|
|
84
|
-
export
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return successResponse(result);
|
|
88
|
-
} catch (error: unknown) {
|
|
89
|
-
if (error instanceof AppError) {
|
|
90
|
-
Logger.warn("Expected domain error caught", { code: error.code, message: error.message });
|
|
91
|
-
return errorResponse(error.message, error.statusCode);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
Logger.error("Unhandled runtime exception in task execution", { error });
|
|
95
|
-
return errorResponse("An unexpected error occurred. Please try again later.", 500);
|
|
83
|
+
import { InternalServerError } from "@/errors/common/internal-server.error";
|
|
84
|
+
|
|
85
|
+
export function normalizeError(caughtError: unknown): AppError {
|
|
86
|
+
if (caughtError instanceof AppError) {
|
|
87
|
+
return caughtError;
|
|
96
88
|
}
|
|
89
|
+
return new InternalServerError({
|
|
90
|
+
message: "Ocurrió un error inesperado al procesar la solicitud.",
|
|
91
|
+
cause: caughtError,
|
|
92
|
+
});
|
|
97
93
|
}
|
|
98
94
|
```
|
|
99
95
|
|
|
@@ -101,5 +97,5 @@ export async function executeSafeTask<T>(task: () => Promise<T>): Promise<Action
|
|
|
101
97
|
|
|
102
98
|
## 4. Templates
|
|
103
99
|
|
|
104
|
-
-
|
|
105
|
-
-
|
|
100
|
+
- `./templates/app-error.template.md`: Complete extensible `AppError` base class with `silent` mode.
|
|
101
|
+
- `./templates/logger.template.md`: Lightweight structured logger with contextual metadata.
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
# Template: AppError Class
|
|
1
|
+
# Template: AppError Base Class
|
|
2
2
|
|
|
3
3
|
Copy or adapt this template when setting up error handling in your project: `src/errors/app-error.ts`.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
|
+
import { Logger } from "@/utils/logger";
|
|
7
|
+
|
|
6
8
|
export interface AppErrorOptions {
|
|
7
9
|
message: string;
|
|
8
10
|
statusCode?: number;
|
|
9
11
|
code?: string;
|
|
10
12
|
details?: unknown;
|
|
11
13
|
cause?: unknown;
|
|
14
|
+
/** When true, suppresses automatic warning logging for expected business conflicts */
|
|
15
|
+
silent?: boolean;
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
export class AppError extends Error {
|
|
@@ -28,6 +32,11 @@ export class AppError extends Error {
|
|
|
28
32
|
if (Error.captureStackTrace) {
|
|
29
33
|
Error.captureStackTrace(this, AppError);
|
|
30
34
|
}
|
|
35
|
+
|
|
36
|
+
if (!options.silent) {
|
|
37
|
+
const logger = new Logger(`API:Error:${this.name}`);
|
|
38
|
+
logger.warn(`${this.code}: ${this.message}`, { details: this.details });
|
|
39
|
+
}
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
toJSON(): Record<string, unknown> {
|