@3lineas/grooveskills 1.5.0 → 1.7.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.
Files changed (27) hide show
  1. package/README.md +8 -0
  2. package/dist/{chunk-PLMYUKGW.js → chunk-E63GTCOE.js} +475 -146
  3. package/dist/chunk-E63GTCOE.js.map +1 -0
  4. package/dist/cli.js +23 -3
  5. package/dist/cli.js.map +1 -1
  6. package/dist/main.js +1 -1
  7. package/package.json +3 -2
  8. package/skills/auth-rbac-policies/SKILL.md +100 -0
  9. package/skills/auth-rbac-policies/references/auth-adapters-examples.md +99 -0
  10. package/skills/caching-rate-limiting/SKILL.md +109 -0
  11. package/skills/caching-rate-limiting/references/cache-rate-limit-examples.md +129 -0
  12. package/skills/clean-architecture-ddd/SKILL.md +1 -0
  13. package/skills/controller-orchestration-queues/SKILL.md +109 -0
  14. package/skills/controller-orchestration-queues/references/controller-queue-examples.md +115 -0
  15. package/skills/docs-standards/SKILL.md +113 -0
  16. package/skills/docs-standards/references/adr-template-and-lifecycle.md +111 -0
  17. package/skills/docs-standards/references/code-comments-and-docstrings.md +147 -0
  18. package/skills/docs-standards/references/relative-paths-and-diagrams.md +123 -0
  19. package/skills/env-feature-flags/SKILL.md +94 -0
  20. package/skills/env-feature-flags/references/env-feature-flags-examples.md +93 -0
  21. package/skills/external-service-adapters/SKILL.md +148 -0
  22. package/skills/external-service-adapters/references/email-and-payment-examples.md +198 -0
  23. package/skills/storage-file-adapters/SKILL.md +125 -0
  24. package/skills/storage-file-adapters/references/storage-adapters-examples.md +164 -0
  25. package/skills/webhook-idempotency/SKILL.md +104 -0
  26. package/skills/webhook-idempotency/references/webhook-idempotency-examples.md +112 -0
  27. package/dist/chunk-PLMYUKGW.js.map +0 -1
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: external-service-adapters
3
+ description: Standardized wrapper & adapter architecture for external third-party services (payment gateways, transactional email, SMS, storage, AI providers). Enforces pure domain ports, isolated vendor SDK wrappers, unified input/output contracts, and interchangeable adapters. TRIGGER when integrating or refactoring any external API, payment provider (Stripe, MercadoPago), email service (Resend, SES, SMTP, Cloudflare), webhook, or cloud provider.
4
+ ---
5
+
6
+ # External Service Adapters & Wrappers
7
+
8
+ Architectural pattern for standardizing third-party integrations (payments, emails, SMS, cloud storage, AI models) using the **Adapter Pattern** and **SDK Wrappers** to prevent vendor lock-in and keep business actions decoupled.
9
+
10
+ ---
11
+
12
+ ## 1. Architectural Distinction: Wrapper vs. Adapter
13
+
14
+ | Component | Responsibility | Location | Dependencies |
15
+ | :--- | :--- | :--- | :--- |
16
+ | **Domain Port (Interface)** | Defines the contract needed by our business domain (pure domain types). | `src/domains/<domain>/` | Zero external SDKs |
17
+ | **Vendor Wrapper (Client)** | Encapsulates vendor SDK initialization, raw authentication, and direct API calls. | `src/infrastructure/<vendor>/` | Vendor SDK / HTTP client |
18
+ | **Service Adapter** | Implements the Domain Port by calling the Vendor Wrapper and translating inputs/outputs. | `src/infrastructure/adapters/` | Port + Vendor Wrapper |
19
+ | **Business Action** | Executes use case logic depending ONLY on the Domain Port. | `src/actions/<domain>/` | Domain Port only |
20
+
21
+ ---
22
+
23
+ ## 2. The 4 Non-Negotiable Rules
24
+
25
+ 1. **Actions Depend on Ports, Never Vendors**:
26
+ - Business actions must type-hint the domain port interface (`EmailSender`, `PaymentGateway`), never `Stripe`, `Resend`, or `aws-sdk`.
27
+ 2. **Standardized Domain Contracts**:
28
+ - Input payloads and return values must use application domain types (e.g., `SendEmailInput`, `SendEmailResult`, `PaymentChargeResult`).
29
+ - Third-party SDK response shapes must never leak into actions or domain modules.
30
+ 3. **Isolate SDK Quirks in Wrappers**:
31
+ - HTTP errors, retries, header formatting, and API keys stay inside the vendor wrapper.
32
+ - Exceptions are converted into standardized application errors (`AppError`) or `ActionResponse` failures.
33
+ 4. **Interchangeability via Factory or Dependency Injection**:
34
+ - Swapping from `Resend` to `AWS SES` or `SMTP` requires only injecting a different adapter without altering a single line of business logic.
35
+
36
+ ---
37
+
38
+ ## 3. Directory Layout
39
+
40
+ ```text
41
+ src/
42
+ ├── actions/
43
+ │ └── billing/
44
+ │ └── process-payment.action.ts # Consumes PaymentGateway port (<80 lines)
45
+ ├── domains/
46
+ │ ├── billing/
47
+ │ │ ├── payment-gateway.port.ts # Domain interface contract (<40 lines)
48
+ │ │ └── billing.types.ts # ChargeInput, ChargeResult (<50 lines)
49
+ │ └── email/
50
+ │ ├── email-sender.port.ts # Domain interface contract (<40 lines)
51
+ │ └── email.types.ts # SendEmailInput, SendEmailResult (<50 lines)
52
+ └── infrastructure/
53
+ ├── adapters/
54
+ │ ├── email/
55
+ │ │ ├── resend-email.adapter.ts # Implements EmailSender (<80 lines)
56
+ │ │ ├── ses-email.adapter.ts # Implements EmailSender (<80 lines)
57
+ │ │ └── smtp-email.adapter.ts # Implements EmailSender (<80 lines)
58
+ │ └── payment/
59
+ │ ├── stripe-payment.adapter.ts # Implements PaymentGateway (<90 lines)
60
+ │ └── mercadopago-payment.adapter.ts # Implements PaymentGateway (<90 lines)
61
+ └── wrappers/
62
+ ├── aws/
63
+ │ └── ses-client.wrapper.ts # Raw AWS SES client wrapper (<70 lines)
64
+ ├── resend/
65
+ │ └── resend-client.wrapper.ts # Raw Resend SDK wrapper (<60 lines)
66
+ └── stripe/
67
+ └── stripe-client.wrapper.ts # Raw Stripe SDK wrapper (<60 lines)
68
+ ```
69
+
70
+ ---
71
+
72
+ ## 4. Implementation Blueprint (Email Example)
73
+
74
+ ### Step 1: Pure Domain Port (`src/domains/email/email-sender.port.ts`)
75
+ ```ts
76
+ export interface SendEmailPayload {
77
+ to: string | string[];
78
+ subject: string;
79
+ htmlContent: string;
80
+ from?: string;
81
+ replyTo?: string;
82
+ }
83
+
84
+ export interface SendEmailOutput {
85
+ id: string;
86
+ delivered: boolean;
87
+ timestamp: Date;
88
+ }
89
+
90
+ export interface EmailSenderPort {
91
+ sendEmail(payload: SendEmailPayload): Promise<SendEmailOutput>;
92
+ }
93
+ ```
94
+
95
+ ### Step 2: Vendor Wrapper (`src/infrastructure/wrappers/resend/resend.wrapper.ts`)
96
+ ```ts
97
+ import { Resend } from "resend";
98
+
99
+ export class ResendWrapper {
100
+ private readonly client: Resend;
101
+
102
+ constructor(apiKey: string) {
103
+ this.client = new Resend(apiKey);
104
+ }
105
+
106
+ async send(params: { from: string; to: string[]; subject: string; html: string }) {
107
+ return this.client.emails.send(params);
108
+ }
109
+ }
110
+ ```
111
+
112
+ ### Step 3: Domain Adapter (`src/infrastructure/adapters/email/resend-email.adapter.ts`)
113
+ ```ts
114
+ import type { EmailSenderPort, SendEmailPayload, SendEmailOutput } from "@/domains/email/email-sender.port";
115
+ import type { ResendWrapper } from "@/infrastructure/wrappers/resend/resend.wrapper";
116
+ import { AppError } from "@/errors/app-error";
117
+
118
+ export class ResendEmailAdapter implements EmailSenderPort {
119
+ constructor(
120
+ private readonly wrapper: ResendWrapper,
121
+ private readonly defaultFrom: string
122
+ ) {}
123
+
124
+ async sendEmail(payload: SendEmailPayload): Promise<SendEmailOutput> {
125
+ const recipients = Array.isArray(payload.to) ? payload.to : [payload.to];
126
+ const { data, error } = await this.wrapper.send({
127
+ from: payload.from ?? this.defaultFrom,
128
+ to: recipients,
129
+ subject: payload.subject,
130
+ html: payload.htmlContent,
131
+ });
132
+
133
+ if (error || !data) {
134
+ throw new AppError({
135
+ message: `Error al enviar correo vía Resend: ${error?.message ?? "desconocido"}`,
136
+ statusCode: 502,
137
+ });
138
+ }
139
+
140
+ return { id: data.id, delivered: true, timestamp: new Date() };
141
+ }
142
+ }
143
+ ```
144
+
145
+ ---
146
+
147
+ ## 5. Reference Guides
148
+ - Detailed concrete implementations for **Email** (SMTP, Resend, AWS SES, Cloudflare Email) and **Payment Gateways** (Stripe, MercadoPago) can be found in `references/email-and-payment-examples.md`.
@@ -0,0 +1,198 @@
1
+ # Implementation Reference: Email & Payment Adapters
2
+
3
+ Reference implementations illustrating the Wrapper + Adapter pattern across email providers and payment gateways with strict typing and guard clauses.
4
+
5
+ ---
6
+
7
+ ## 1. Transactional Email System
8
+
9
+ ### 1.1 Pure Domain Port (`src/domains/email/email-sender.port.ts`)
10
+ ```ts
11
+ export interface EmailRecipient {
12
+ email: string;
13
+ name?: string;
14
+ }
15
+
16
+ export interface SendEmailInput {
17
+ to: EmailRecipient[];
18
+ subject: string;
19
+ html: string;
20
+ text?: string;
21
+ from?: EmailRecipient;
22
+ replyTo?: string;
23
+ }
24
+
25
+ export interface SendEmailResult {
26
+ messageId: string;
27
+ provider: "smtp" | "resend" | "aws-ses" | "cloudflare";
28
+ sentAt: Date;
29
+ }
30
+
31
+ export interface EmailSenderPort {
32
+ send(input: SendEmailInput): Promise<SendEmailResult>;
33
+ }
34
+ ```
35
+
36
+ ### 1.2 SMTP Adapter (`nodemailer`)
37
+ ```ts
38
+ // src/infrastructure/adapters/email/smtp-email.adapter.ts
39
+ import type { EmailSenderPort, SendEmailInput, SendEmailResult } from "@/domains/email/email-sender.port";
40
+ import type { SmtpWrapper } from "@/infrastructure/wrappers/smtp/smtp.wrapper";
41
+ import { AppError } from "@/errors/app-error";
42
+
43
+ export class SmtpEmailAdapter implements EmailSenderPort {
44
+ constructor(private wrapper: SmtpWrapper, private defaultSender: string) {}
45
+
46
+ async send(input: SendEmailInput): Promise<SendEmailResult> {
47
+ try {
48
+ const from = input.from ? `${input.from.name ?? ""} <${input.from.email}>`.trim() : this.defaultSender;
49
+ const info = await this.wrapper.sendMail({
50
+ from,
51
+ to: input.to.map((r) => r.email).join(", "),
52
+ subject: input.subject,
53
+ html: input.html,
54
+ text: input.text,
55
+ });
56
+ return { messageId: info.messageId, provider: "smtp", sentAt: new Date() };
57
+ } catch (err) {
58
+ throw new AppError({ message: "Fallo al enviar correo SMTP.", statusCode: 502, cause: err });
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ ### 1.3 AWS SES Adapter (`@aws-sdk/client-ses`)
65
+ ```ts
66
+ // src/infrastructure/adapters/email/ses-email.adapter.ts
67
+ import type { EmailSenderPort, SendEmailInput, SendEmailResult } from "@/domains/email/email-sender.port";
68
+ import type { SesWrapper } from "@/infrastructure/wrappers/aws/ses.wrapper";
69
+ import { AppError } from "@/errors/app-error";
70
+
71
+ export class SesEmailAdapter implements EmailSenderPort {
72
+ constructor(private wrapper: SesWrapper, private defaultSource: string) {}
73
+
74
+ async send(input: SendEmailInput): Promise<SendEmailResult> {
75
+ const res = await this.wrapper.send({
76
+ Source: input.from?.email ?? this.defaultSource,
77
+ Destination: { ToAddresses: input.to.map((t) => t.email) },
78
+ Message: {
79
+ Subject: { Data: input.subject, Charset: "UTF-8" },
80
+ Body: { Html: { Data: input.html, Charset: "UTF-8" } },
81
+ },
82
+ });
83
+
84
+ if (!res.MessageId) {
85
+ throw new AppError({ message: "Fallo al enviar correo con AWS SES.", statusCode: 502 });
86
+ }
87
+ return { messageId: res.MessageId, provider: "aws-ses", sentAt: new Date() };
88
+ }
89
+ }
90
+ ```
91
+
92
+ ### 1.4 Cloudflare Email Routing Adapter
93
+ ```ts
94
+ // src/infrastructure/adapters/email/cloudflare-email.adapter.ts
95
+ import type { EmailSenderPort, SendEmailInput, SendEmailResult } from "@/domains/email/email-sender.port";
96
+ import type { CloudflareEmailWrapper } from "@/infrastructure/wrappers/cloudflare/cloudflare-email.wrapper";
97
+ import { AppError } from "@/errors/app-error";
98
+
99
+ export class CloudflareEmailAdapter implements EmailSenderPort {
100
+ constructor(private wrapper: CloudflareEmailWrapper, private defaultSender: string) {}
101
+
102
+ async send(input: SendEmailInput): Promise<SendEmailResult> {
103
+ const res = await this.wrapper.postEmail({
104
+ from: input.from?.email ?? this.defaultSender,
105
+ to: input.to.map((t) => t.email),
106
+ subject: input.subject,
107
+ content: input.html,
108
+ });
109
+ if (!res.ok) {
110
+ throw new AppError({ message: "Error al enviar correo con Cloudflare Email.", statusCode: 502 });
111
+ }
112
+ const data = await res.json() as { result?: { id?: string } };
113
+ return { messageId: data.result?.id ?? `cf_${Date.now()}`, provider: "cloudflare", sentAt: new Date() };
114
+ }
115
+ }
116
+ ```
117
+
118
+ ---
119
+
120
+ ## 2. Payment Gateways (Stripe & MercadoPago)
121
+
122
+ ### 2.1 Domain Port (`src/domains/billing/payment-gateway.port.ts`)
123
+ ```ts
124
+ export interface ChargeInput {
125
+ amountInCents: number;
126
+ currency: "usd" | "clp" | "mxn" | "eur";
127
+ customerId: string;
128
+ sourceToken?: string;
129
+ idempotencyKey: string;
130
+ }
131
+
132
+ export interface ChargeResult {
133
+ transactionId: string;
134
+ status: "succeeded" | "pending" | "failed";
135
+ provider: "stripe" | "mercadopago";
136
+ }
137
+
138
+ export interface PaymentGatewayPort {
139
+ createCharge(input: ChargeInput): Promise<ChargeResult>;
140
+ }
141
+ ```
142
+
143
+ ### 2.2 Stripe & MercadoPago Adapters
144
+ ```ts
145
+ // src/infrastructure/adapters/payment/stripe-payment.adapter.ts
146
+ import type { PaymentGatewayPort, ChargeInput, ChargeResult } from "@/domains/billing/payment-gateway.port";
147
+ import type Stripe from "stripe";
148
+ import { AppError } from "@/errors/app-error";
149
+
150
+ export class StripePaymentAdapter implements PaymentGatewayPort {
151
+ constructor(private stripe: Stripe) {}
152
+
153
+ async createCharge(input: ChargeInput): Promise<ChargeResult> {
154
+ try {
155
+ const intent = await this.stripe.paymentIntents.create(
156
+ { amount: input.amountInCents, currency: input.currency, customer: input.customerId, confirm: true },
157
+ { idempotencyKey: input.idempotencyKey }
158
+ );
159
+ return {
160
+ transactionId: intent.id,
161
+ status: intent.status === "succeeded" ? "succeeded" : "pending",
162
+ provider: "stripe",
163
+ };
164
+ } catch (err) {
165
+ throw new AppError({ message: "Fallo en Stripe.", statusCode: 402, cause: err });
166
+ }
167
+ }
168
+ }
169
+ ```
170
+ ```ts
171
+ // src/infrastructure/adapters/payment/mercadopago-payment.adapter.ts
172
+ import type { PaymentGatewayPort, ChargeInput, ChargeResult } from "@/domains/billing/payment-gateway.port";
173
+ import { Payment, type MercadoPagoConfig } from "mercadopago";
174
+ import { AppError } from "@/errors/app-error";
175
+
176
+ export class MercadoPagoPaymentAdapter implements PaymentGatewayPort {
177
+ private client: Payment;
178
+ constructor(cfg: MercadoPagoConfig) {
179
+ this.client = new Payment(cfg);
180
+ }
181
+
182
+ async createCharge(input: ChargeInput): Promise<ChargeResult> {
183
+ try {
184
+ const res = await this.client.create({
185
+ body: { transaction_amount: input.amountInCents / 100, token: input.sourceToken, payer: { id: input.customerId } },
186
+ requestOptions: { idempotencyKey: input.idempotencyKey },
187
+ });
188
+ return {
189
+ transactionId: String(res.id),
190
+ status: res.status === "approved" ? "succeeded" : "pending",
191
+ provider: "mercadopago",
192
+ };
193
+ } catch (err) {
194
+ throw new AppError({ message: "Fallo en MercadoPago.", statusCode: 402, cause: err });
195
+ }
196
+ }
197
+ }
198
+ ```
@@ -0,0 +1,125 @@
1
+ ---
2
+ name: storage-file-adapters
3
+ description: Standardized wrapper & adapter architecture for object storage, uploads, presigned URLs, and streaming (AWS S3, Cloudflare R2, Supabase Storage, In-Memory). Discards repository abstractions in favor of direct ports and isolated vendor clients. TRIGGER when implementing file uploads, asset storage, cloud buckets, multipart uploads, or presigned download/upload URLs.
4
+ ---
5
+
6
+ # Object Storage & File Adapters (Wrappers + Ports)
7
+
8
+ Opinionated architectural pattern for object storage and file management (AWS S3, Cloudflare R2, Supabase Storage, In-Memory) utilizing the **Wrapper + Adapter** pattern instead of generic repositories.
9
+
10
+ ---
11
+
12
+ ## 1. Core Principles
13
+
14
+ 1. **Storage is an I/O Port, Not a Repository**:
15
+ - Files are blobs with metadata and URLs, not relational entities. We define `StoragePort`, not `FileRepository`.
16
+ 2. **Standardized Domain Payload**:
17
+ - Operations consume and return pure domain contracts (`PutFileInput`, `PutFileOutput`, `PresignedUrlInput`).
18
+ - Vendor response shapes (S3 `PutObjectCommandOutput`, Supabase `StorageError`) never escape the adapter boundary.
19
+ 3. **Vendor Wrappers Isolate Credentials & SDK Quirks**:
20
+ - SDK clients, endpoint URLs, region configs, and multipart batching logic remain strictly inside `src/infrastructure/wrappers/storage/`.
21
+ 4. **Zero-Change Provider Portability**:
22
+ - Switching between AWS S3, Cloudflare R2, Supabase Storage, or an In-Memory mock during local testing requires swapping only the adapter instance.
23
+
24
+ ---
25
+
26
+ ## 2. Directory Layout
27
+
28
+ ```text
29
+ src/
30
+ ├── actions/
31
+ │ └── media/
32
+ │ └── upload-avatar.action.ts # Consumes StoragePort (<80 lines)
33
+ ├── domains/
34
+ │ └── storage/
35
+ │ ├── storage.port.ts # Domain interface contract (<50 lines)
36
+ │ └── storage.types.ts # Pure domain payloads (<50 lines)
37
+ └── infrastructure/
38
+ ├── adapters/
39
+ │ └── storage/
40
+ │ ├── s3-storage.adapter.ts # AWS S3 implementation (<90 lines)
41
+ │ ├── r2-storage.adapter.ts # Cloudflare R2 implementation (<90 lines)
42
+ │ ├── supabase-storage.adapter.ts # Supabase Storage implementation (<90 lines)
43
+ │ └── memory-storage.adapter.ts # In-memory testing adapter (<70 lines)
44
+ └── wrappers/
45
+ └── storage/
46
+ ├── s3-client.wrapper.ts # AWS SDK client wrapper (<70 lines)
47
+ ├── r2-client.wrapper.ts # Cloudflare S3-compatible client (<70 lines)
48
+ └── supabase-storage.wrapper.ts # Supabase storage client (<60 lines)
49
+ ```
50
+
51
+ ---
52
+
53
+ ## 3. Implementation Contract (`src/domains/storage/storage.port.ts`)
54
+
55
+ ```ts
56
+ export interface StorageFile {
57
+ buffer: Uint8Array | Buffer;
58
+ contentType: string;
59
+ sizeBytes: number;
60
+ }
61
+
62
+ export interface PutFileInput {
63
+ bucket: string;
64
+ key: string;
65
+ file: StorageFile;
66
+ isPublic?: boolean;
67
+ }
68
+
69
+ export interface PutFileOutput {
70
+ key: string;
71
+ bucket: string;
72
+ publicUrl?: string;
73
+ etag?: string;
74
+ }
75
+
76
+ export interface PresignedUrlInput {
77
+ bucket: string;
78
+ key: string;
79
+ operation: "getObject" | "putObject";
80
+ expiresInSeconds: number;
81
+ }
82
+
83
+ export interface StoragePort {
84
+ putFile(input: PutFileInput): Promise<PutFileOutput>;
85
+ getFile(bucket: string, key: string): Promise<StorageFile>;
86
+ deleteFile(bucket: string, key: string): Promise<void>;
87
+ createPresignedUrl(input: PresignedUrlInput): Promise<string>;
88
+ }
89
+ ```
90
+
91
+ ---
92
+
93
+ ## 4. Usage in Business Actions
94
+
95
+ ```ts
96
+ import type { StoragePort } from "@/domains/storage/storage.port";
97
+ import { AppError } from "@/errors/app-error";
98
+
99
+ export async function uploadAvatarAction(
100
+ storage: StoragePort,
101
+ userId: string,
102
+ rawFile: Uint8Array,
103
+ mimeType: string
104
+ ): Promise<{ avatarUrl: string }> {
105
+ if (rawFile.byteLength > 5 * 1024 * 1024) {
106
+ throw new AppError({ message: "La imagen excede el límite de 5 MB.", statusCode: 400 });
107
+ }
108
+
109
+ const result = await storage.putFile({
110
+ bucket: "avatars",
111
+ key: `users/${userId}/avatar.webp`,
112
+ file: { buffer: rawFile, contentType: mimeType, sizeBytes: rawFile.byteLength },
113
+ isPublic: true,
114
+ });
115
+
116
+ return { avatarUrl: result.publicUrl ?? result.key };
117
+ }
118
+ ```
119
+
120
+ ---
121
+
122
+ ## 5. Detailed Provider Blueprints
123
+
124
+ For complete production adapters (AWS S3, Cloudflare R2, Supabase Storage, and In-Memory testing fake), consult:
125
+ - `references/storage-adapters-examples.md`
@@ -0,0 +1,164 @@
1
+ # Implementation Reference: Object Storage & File Adapters
2
+
3
+ Reference implementations for S3, Cloudflare R2, Supabase Storage, and In-Memory testing fake under the Wrapper + Adapter architecture.
4
+
5
+ ---
6
+
7
+ ## 1. AWS S3 Adapter (`@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner`)
8
+
9
+ ```ts
10
+ // src/infrastructure/adapters/storage/s3-storage.adapter.ts
11
+ import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
12
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
13
+ import type { StoragePort, PutFileInput, PutFileOutput, StorageFile, PresignedUrlInput } from "@/domains/storage/storage.port";
14
+ import { AppError } from "@/errors/app-error";
15
+
16
+ export class S3StorageAdapter implements StoragePort {
17
+ constructor(private client: S3Client, private publicBaseUrl?: string) {}
18
+
19
+ async putFile(input: PutFileInput): Promise<PutFileOutput> {
20
+ try {
21
+ const res = await this.client.send(
22
+ new PutObjectCommand({
23
+ Bucket: input.bucket,
24
+ Key: input.key,
25
+ Body: input.file.buffer,
26
+ ContentType: input.file.contentType,
27
+ })
28
+ );
29
+ const publicUrl = this.publicBaseUrl ? `${this.publicBaseUrl.replace(/\/$/, "")}/${input.key}` : undefined;
30
+ return { key: input.key, bucket: input.bucket, publicUrl, etag: res.ETag };
31
+ } catch (err) {
32
+ throw new AppError({ message: "Error al subir archivo a S3.", statusCode: 502, cause: err });
33
+ }
34
+ }
35
+
36
+ async getFile(bucket: string, key: string): Promise<StorageFile> {
37
+ const res = await this.client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
38
+ const bytes = await res.Body?.transformToByteArray();
39
+ if (!bytes) {
40
+ throw new AppError({ message: "Archivo no encontrado o vacío.", statusCode: 404 });
41
+ }
42
+ return { buffer: bytes, contentType: res.ContentType ?? "application/octet-stream", sizeBytes: res.ContentLength ?? bytes.byteLength };
43
+ }
44
+
45
+ async deleteFile(bucket: string, key: string): Promise<void> {
46
+ await this.client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
47
+ }
48
+
49
+ async createPresignedUrl(input: PresignedUrlInput): Promise<string> {
50
+ const Command = input.operation === "getObject" ? GetObjectCommand : PutObjectCommand;
51
+ return getSignedUrl(this.client, new Command({ Bucket: input.bucket, Key: input.key }), { expiresIn: input.expiresInSeconds });
52
+ }
53
+ }
54
+ ```
55
+
56
+ ---
57
+
58
+ ## 2. Cloudflare R2 Adapter (S3-Compatible)
59
+
60
+ ```ts
61
+ // src/infrastructure/adapters/storage/r2-storage.adapter.ts
62
+ import { S3Client } from "@aws-sdk/client-s3";
63
+ import { S3StorageAdapter } from "./s3-storage.adapter";
64
+
65
+ export function createR2StorageAdapter(accountId: string, accessKeyId: string, secretAccessKey: string, publicUrl?: string) {
66
+ const client = new S3Client({
67
+ region: "auto",
68
+ endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
69
+ credentials: { accessKeyId, secretAccessKey },
70
+ });
71
+ return new S3StorageAdapter(client, publicUrl);
72
+ }
73
+ ```
74
+
75
+ ---
76
+
77
+ ## 3. Supabase Storage Adapter (`@supabase/storage-js`)
78
+
79
+ ```ts
80
+ // src/infrastructure/adapters/storage/supabase-storage.adapter.ts
81
+ import type { StorageClient } from "@supabase/storage-js";
82
+ import type { StoragePort, PutFileInput, PutFileOutput, StorageFile, PresignedUrlInput } from "@/domains/storage/storage.port";
83
+ import { AppError } from "@/errors/app-error";
84
+
85
+ export class SupabaseStorageAdapter implements StoragePort {
86
+ constructor(private client: StorageClient) {}
87
+
88
+ async putFile(input: PutFileInput): Promise<PutFileOutput> {
89
+ const { data, error } = await this.client.from(input.bucket).upload(input.key, input.file.buffer, {
90
+ contentType: input.file.contentType,
91
+ upsert: true,
92
+ });
93
+ if (error || !data) {
94
+ throw new AppError({ message: "Error al almacenar archivo en Supabase.", statusCode: 502, cause: error });
95
+ }
96
+ const publicUrl = input.isPublic ? this.client.from(input.bucket).getPublicUrl(input.key).data.publicUrl : undefined;
97
+ return { key: input.key, bucket: input.bucket, publicUrl };
98
+ }
99
+
100
+ async getFile(bucket: string, key: string): Promise<StorageFile> {
101
+ const { data, error } = await this.client.from(bucket).download(key);
102
+ if (error || !data) {
103
+ throw new AppError({ message: "Archivo no encontrado.", statusCode: 404, cause: error });
104
+ }
105
+ const buffer = new Uint8Array(await data.arrayBuffer());
106
+ return { buffer, contentType: data.type || "application/octet-stream", sizeBytes: buffer.byteLength };
107
+ }
108
+
109
+ async deleteFile(bucket: string, key: string): Promise<void> {
110
+ const { error } = await this.client.from(bucket).remove([key]);
111
+ if (error) {
112
+ throw new AppError({ message: "Error al eliminar archivo en Supabase.", statusCode: 502, cause: error });
113
+ }
114
+ }
115
+
116
+ async createPresignedUrl(input: PresignedUrlInput): Promise<string> {
117
+ if (input.operation !== "getObject") {
118
+ throw new AppError({ message: "Operación de presigned URL no soportada.", statusCode: 400 });
119
+ }
120
+ const { data, error } = await this.client.from(input.bucket).createSignedUrl(input.key, input.expiresInSeconds);
121
+ if (error || !data?.signedUrl) {
122
+ throw new AppError({ message: "No se pudo generar URL firmada.", statusCode: 502, cause: error });
123
+ }
124
+ return data.signedUrl;
125
+ }
126
+ }
127
+ ```
128
+
129
+ ---
130
+
131
+ ## 4. In-Memory Storage Adapter (Testing Fake)
132
+
133
+ ```ts
134
+ // src/infrastructure/adapters/storage/memory-storage.adapter.ts
135
+ import type { StoragePort, PutFileInput, PutFileOutput, StorageFile, PresignedUrlInput } from "@/domains/storage/storage.port";
136
+ import { AppError } from "@/errors/app-error";
137
+
138
+ export class MemoryStorageAdapter implements StoragePort {
139
+ private files = new Map<string, StorageFile>();
140
+
141
+ private keyOf(bucket: string, key: string): string {
142
+ return `${bucket}:${key}`;
143
+ }
144
+
145
+ async putFile(input: PutFileInput): Promise<PutFileOutput> {
146
+ this.files.set(this.keyOf(input.bucket, input.key), input.file);
147
+ return { key: input.key, bucket: input.bucket, publicUrl: `https://mock.storage/${input.key}` };
148
+ }
149
+
150
+ async getFile(bucket: string, key: string): Promise<StorageFile> {
151
+ const file = this.files.get(this.keyOf(bucket, key));
152
+ if (!file) throw new AppError({ message: "Archivo inexistente en memoria.", statusCode: 404 });
153
+ return file;
154
+ }
155
+
156
+ async deleteFile(bucket: string, key: string): Promise<void> {
157
+ this.files.delete(this.keyOf(bucket, key));
158
+ }
159
+
160
+ async createPresignedUrl(input: PresignedUrlInput): Promise<string> {
161
+ return `https://mock.storage/presigned/${input.bucket}/${input.key}?expires=${input.expiresInSeconds}`;
162
+ }
163
+ }
164
+ ```