@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.
@@ -0,0 +1,94 @@
1
+ ---
2
+ name: env-feature-flags
3
+ description: Architectural standards for fail-fast environment validation with Valibot and decoupled Feature Flags wrappers with swappable adapters (PostHog, LaunchDarkly, Unleash, In-Memory). Guarantees that applications crash at boot time on invalid config and allows toggling features without coupling code to vendor SDKs. TRIGGER when reading environment variables, structuring process.env, configuring feature flags, or running A/B experiments.
4
+ ---
5
+
6
+ # Fail-Fast Environment Validation & Feature Flags Adapters
7
+
8
+ Opinionated architectural standard combining **Fail-Fast Environment Schema Validation** (using Valibot) and **Feature Flags Adapters** (PostHog, LaunchDarkly, In-Memory).
9
+
10
+ ---
11
+
12
+ ## 1. Architectural Invariants
13
+
14
+ 1. **Fail-Fast at Startup**:
15
+ - Environment variables must be validated on application boot. If a required secret or URL is missing or malformed, the process exits immediately with a clean diagnostic message.
16
+ - Raw `process.env` is NEVER accessed directly inside business actions or controllers. All code imports the strongly typed `env` singleton.
17
+ 2. **Feature Flags as a Domain Port**:
18
+ - Business actions evaluate feature availability through `FeatureFlagPort`.
19
+ - Vendor SDKs (PostHog, LaunchDarkly, Unleash) remain isolated inside infrastructure adapters.
20
+
21
+ ---
22
+
23
+ ## 2. Directory Layout
24
+
25
+ ```text
26
+ src/
27
+ ├── config/
28
+ │ └── env.ts # Fail-fast Valibot environment parser (<70 lines)
29
+ ├── domains/
30
+ │ └── feature-flags/
31
+ │ ├── feature-flag.port.ts # Domain interface for flags (<40 lines)
32
+ │ └── flag-keys.ts # Strongly typed flag registry (<30 lines)
33
+ └── infrastructure/
34
+ └── adapters/
35
+ └── feature-flags/
36
+ ├── posthog-flags.adapter.ts # PostHog implementation (<70 lines)
37
+ ├── launchdarkly-flags.adapter.ts # LaunchDarkly implementation (<70 lines)
38
+ └── memory-flags.adapter.ts # In-memory testing flags fake (<50 lines)
39
+ ```
40
+
41
+ ---
42
+
43
+ ## 3. Fail-Fast Environment Config (`src/config/env.ts`)
44
+
45
+ ```ts
46
+ import * as v from "valibot";
47
+
48
+ const EnvSchema = v.object({
49
+ NODE_ENV: v.fallback(v.picklist(["development", "production", "test"]), "development"),
50
+ PORT: v.fallback(v.pipe(v.string(), v.transform(Number)), 3000),
51
+ DATABASE_URL: v.pipe(v.string(), v.url("DATABASE_URL debe ser una URL válida.")),
52
+ API_SECRET_KEY: v.pipe(v.string(), v.minLength(32, "API_SECRET_KEY requiere al menos 32 caracteres.")),
53
+ });
54
+
55
+ function loadEnv() {
56
+ const result = v.safeParse(EnvSchema, process.env);
57
+ if (!result.success) {
58
+ console.error("❌ Error fatal en variables de entorno:");
59
+ for (const issue of result.issues) {
60
+ console.error(` - ${issue.path?.[0]?.key}: ${issue.message}`);
61
+ }
62
+ process.exit(1);
63
+ }
64
+ return result.output;
65
+ }
66
+
67
+ export const env = loadEnv();
68
+ ```
69
+
70
+ ---
71
+
72
+ ## 4. Feature Flag Port (`src/domains/feature-flags/feature-flag.port.ts`)
73
+
74
+ ```ts
75
+ export type FeatureFlagKey = "new-checkout-flow" | "ai-copilot" | "export-pdf";
76
+
77
+ export interface FeatureFlagEvaluationContext {
78
+ distinctId: string;
79
+ organizationId?: string;
80
+ properties?: Record<string, unknown>;
81
+ }
82
+
83
+ export interface FeatureFlagPort {
84
+ isEnabled(key: FeatureFlagKey, context?: FeatureFlagEvaluationContext): Promise<boolean>;
85
+ getVariant<T = string>(key: FeatureFlagKey, context?: FeatureFlagEvaluationContext): Promise<T | null>;
86
+ }
87
+ ```
88
+
89
+ ---
90
+
91
+ ## 5. Detailed Adapters Reference
92
+
93
+ For real implementations of PostHog, LaunchDarkly, and In-Memory Feature Flag adapters, see:
94
+ - `references/env-feature-flags-examples.md`
@@ -0,0 +1,93 @@
1
+ # Implementation Reference: Env & Feature Flag Adapters
2
+
3
+ Production implementations of PostHog, LaunchDarkly, and In-Memory testing feature flag adapters.
4
+
5
+ ---
6
+
7
+ ## 1. PostHog Feature Flags Adapter (`posthog-node`)
8
+
9
+ ```ts
10
+ // src/infrastructure/adapters/feature-flags/posthog-flags.adapter.ts
11
+ import { PostHog } from "posthog-node";
12
+ import type { FeatureFlagPort, FeatureFlagKey, FeatureFlagEvaluationContext } from "@/domains/feature-flags/feature-flag.port";
13
+
14
+ export class PostHogFeatureFlagAdapter implements FeatureFlagPort {
15
+ constructor(private client: PostHog) {}
16
+
17
+ async isEnabled(key: FeatureFlagKey, context?: FeatureFlagEvaluationContext): Promise<boolean> {
18
+ const distinctId = context?.distinctId ?? "anonymous";
19
+ const res = await this.client.isFeatureEnabled(key, distinctId, {
20
+ personProperties: context?.properties,
21
+ });
22
+ return Boolean(res);
23
+ }
24
+
25
+ async getVariant<T = string>(key: FeatureFlagKey, context?: FeatureFlagEvaluationContext): Promise<T | null> {
26
+ const distinctId = context?.distinctId ?? "anonymous";
27
+ const variant = await this.client.getFeatureFlag(key, distinctId, {
28
+ personProperties: context?.properties,
29
+ });
30
+ return (variant as T) ?? null;
31
+ }
32
+ }
33
+ ```
34
+
35
+ ---
36
+
37
+ ## 2. LaunchDarkly Feature Flags Adapter (`@launchdarkly/node-server-sdk`)
38
+
39
+ ```ts
40
+ // src/infrastructure/adapters/feature-flags/launchdarkly-flags.adapter.ts
41
+ import type { LDClient } from "@launchdarkly/node-server-sdk";
42
+ import type { FeatureFlagPort, FeatureFlagKey, FeatureFlagEvaluationContext } from "@/domains/feature-flags/feature-flag.port";
43
+
44
+ export class LaunchDarklyFeatureFlagAdapter implements FeatureFlagPort {
45
+ constructor(private client: LDClient) {}
46
+
47
+ async isEnabled(key: FeatureFlagKey, context?: FeatureFlagEvaluationContext): Promise<boolean> {
48
+ const user = {
49
+ key: context?.distinctId ?? "anonymous",
50
+ custom: { organizationId: context?.organizationId, ...context?.properties },
51
+ };
52
+ return this.client.variation(key, user, false);
53
+ }
54
+
55
+ async getVariant<T = string>(key: FeatureFlagKey, context?: FeatureFlagEvaluationContext): Promise<T | null> {
56
+ const user = {
57
+ key: context?.distinctId ?? "anonymous",
58
+ custom: { organizationId: context?.organizationId, ...context?.properties },
59
+ };
60
+ return this.client.variation(key, user, null);
61
+ }
62
+ }
63
+ ```
64
+
65
+ ---
66
+
67
+ ## 3. In-Memory Testing Flags Adapter
68
+
69
+ ```ts
70
+ // src/infrastructure/adapters/feature-flags/memory-flags.adapter.ts
71
+ import type { FeatureFlagPort, FeatureFlagKey } from "@/domains/feature-flags/feature-flag.port";
72
+
73
+ export class MemoryFeatureFlagAdapter implements FeatureFlagPort {
74
+ private flags = new Map<string, boolean>();
75
+ private variants = new Map<string, unknown>();
76
+
77
+ setFlag(key: FeatureFlagKey, enabled: boolean): void {
78
+ this.flags.set(key, enabled);
79
+ }
80
+
81
+ setVariant(key: FeatureFlagKey, variant: unknown): void {
82
+ this.variants.set(key, variant);
83
+ }
84
+
85
+ async isEnabled(key: FeatureFlagKey): Promise<boolean> {
86
+ return this.flags.get(key) ?? false;
87
+ }
88
+
89
+ async getVariant<T = string>(key: FeatureFlagKey): Promise<T | null> {
90
+ return (this.variants.get(key) as T) ?? null;
91
+ }
92
+ }
93
+ ```
@@ -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`