@mariachi/core 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/rules/mariachi.mdc +36 -0
- package/.mariachi/architecture.md +46 -0
- package/.mariachi/conventions.md +109 -0
- package/.mariachi/packages.md +66 -0
- package/.mariachi/patterns.md +71 -0
- package/README.md +42 -0
- package/dist/index.cjs +423 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +211 -0
- package/dist/index.d.ts +211 -0
- package/dist/index.js +362 -0
- package/dist/index.js.map +1 -0
- package/docs/adr/001-adapter-pattern.md +42 -0
- package/docs/ai-guide.md +222 -0
- package/docs/architecture.md +110 -0
- package/docs/conventions.md +109 -0
- package/docs/improvements/20260224231420_notifications_realtime_nats.md +82 -0
- package/docs/integrations.md +70 -0
- package/docs/packages.md +66 -0
- package/docs/patterns.md +71 -0
- package/docs/recipes/add-background-job.md +156 -0
- package/docs/recipes/add-domain-entity.md +248 -0
- package/docs/recipes/add-integration.md +198 -0
- package/docs/recipes/add-webhook-endpoint.md +169 -0
- package/docs/recipes/wiring-and-bootstrap.md +250 -0
- package/docs/runbook.md +84 -0
- package/package.json +38 -0
package/docs/patterns.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Mariachi Core Patterns
|
|
2
|
+
|
|
3
|
+
## 1. Adapter Factory
|
|
4
|
+
|
|
5
|
+
Every external dependency is behind an adapter. A factory function selects the implementation from config.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
const cache = createCache({ adapter: 'redis', url: process.env.REDIS_URL });
|
|
9
|
+
const search = createSearch({ adapter: 'typesense', ... });
|
|
10
|
+
const jobQueue = createJobQueue({ adapter: 'bullmq', redisUrl: ... }, logger);
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Production adapters: Redis, PostgreSQL, Stripe, Typesense, BullMQ, Resend, S3, OpenAI.
|
|
14
|
+
Test doubles live in `@mariachi/testing` (e.g. `TestCacheClient`, `TestEventBus`).
|
|
15
|
+
|
|
16
|
+
## 2. Abstract Service Class
|
|
17
|
+
|
|
18
|
+
Each package exposes: `X` (abstract) → `DefaultX extends X`. The abstract class layers observability, error handling, and hooks on top of raw adapters.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
export abstract class Notifications implements Instrumentable { ... }
|
|
22
|
+
export class DefaultNotifications extends Notifications { ... }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Subclass `DefaultX` when custom behavior is needed.
|
|
26
|
+
|
|
27
|
+
## 3. Instrumentable & Disposable
|
|
28
|
+
|
|
29
|
+
Two interfaces from `@mariachi/core` that every service implements:
|
|
30
|
+
|
|
31
|
+
- **Instrumentable**: `{ logger, tracer?, metrics? }` — pulled from the DI container
|
|
32
|
+
- **Disposable**: `{ connect(), disconnect(), isHealthy() }` — lifecycle management
|
|
33
|
+
|
|
34
|
+
## 4. DI Container
|
|
35
|
+
|
|
36
|
+
Global container via `getContainer()` with well-known `KEYS`:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { getContainer, KEYS } from '@mariachi/core';
|
|
40
|
+
const container = getContainer();
|
|
41
|
+
container.register(KEYS.Logger, logger);
|
|
42
|
+
const logger = container.resolve<Logger>(KEYS.Logger);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`bootstrap()` registers `Config` and `Logger` automatically. Other services register themselves as needed.
|
|
46
|
+
|
|
47
|
+
## 5. Context Propagation
|
|
48
|
+
|
|
49
|
+
Every operation receives a `Context` carrying identity and tracing:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
interface Context {
|
|
53
|
+
traceId: string;
|
|
54
|
+
userId: string | null;
|
|
55
|
+
tenantId: string | null;
|
|
56
|
+
scopes: string[];
|
|
57
|
+
identityType: string;
|
|
58
|
+
logger: Logger;
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Never lose context between layers. Pass `ctx` through communication calls, service methods, and event handlers.
|
|
63
|
+
|
|
64
|
+
## 6. Zod Schemas at Boundaries
|
|
65
|
+
|
|
66
|
+
Validate at entry points, not deep in business logic:
|
|
67
|
+
|
|
68
|
+
- **Controller**: parse request body with Zod before calling communication
|
|
69
|
+
- **Handler registration**: declare `schema: { input, output }` with Zod schemas
|
|
70
|
+
- **Job definition**: declare `schema` for job payload
|
|
71
|
+
- **Config**: `AppConfigSchema` validates all configuration at load time
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Recipe: Add a Background Job
|
|
2
|
+
|
|
3
|
+
This walks through defining a new job, registering it in the worker, and enqueuing it from a service.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Define the Job
|
|
8
|
+
|
|
9
|
+
Jobs are plain objects with `name`, `schema` (Zod), `retry` config, and a `handler`.
|
|
10
|
+
|
|
11
|
+
**File:** e.g. `src/jobs/process-order.job.ts`
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
|
|
16
|
+
export const ProcessOrderJob = {
|
|
17
|
+
name: 'orders.process',
|
|
18
|
+
schema: z.object({
|
|
19
|
+
orderId: z.string(),
|
|
20
|
+
userId: z.string(),
|
|
21
|
+
}),
|
|
22
|
+
retry: { attempts: 3, backoff: 'exponential' as const },
|
|
23
|
+
handler: async (
|
|
24
|
+
data: { orderId: string; userId: string },
|
|
25
|
+
ctx: { logger: any; traceId: string; attemptNumber: number; jobId: string },
|
|
26
|
+
) => {
|
|
27
|
+
ctx.logger.info({ orderId: data.orderId }, 'Processing order');
|
|
28
|
+
// business logic here
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Job handler signature:**
|
|
34
|
+
- `data` — validated payload (matches the Zod schema)
|
|
35
|
+
- `ctx` — job context with `logger`, `traceId`, `attemptNumber`, `jobId`
|
|
36
|
+
|
|
37
|
+
**Retry options:**
|
|
38
|
+
- `attempts` — max retries
|
|
39
|
+
- `backoff` — `'exponential'` or `'fixed'`
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 2. Register the Job in the Worker
|
|
44
|
+
|
|
45
|
+
Add the job to the worker entry point.
|
|
46
|
+
|
|
47
|
+
**File:** e.g. `src/worker/index.ts`
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { ProcessOrderJob } from './jobs/process-order.job';
|
|
51
|
+
|
|
52
|
+
jobQueue.registerJob(ProcessOrderJob);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 3. Add a Schedule (Optional)
|
|
58
|
+
|
|
59
|
+
If the job should run on a cron schedule, add an entry to your schedules.
|
|
60
|
+
|
|
61
|
+
**File:** e.g. `src/jobs/schedules.ts`
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
export const schedules = [
|
|
65
|
+
{ name: 'daily-cleanup', cron: '0 0 * * *', jobName: 'system.cleanup', data: {} },
|
|
66
|
+
{ name: 'hourly-order-check', cron: '0 * * * *', jobName: 'orders.process', data: { orderId: 'batch', userId: 'system' } },
|
|
67
|
+
];
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Register in the worker entry:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
for (const schedule of schedules) {
|
|
74
|
+
jobQueue.register(schedule);
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 4. Enqueue from a Service
|
|
81
|
+
|
|
82
|
+
To trigger a job from business logic, use `jobQueue.enqueue()`.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { createJobQueue } from '@mariachi/jobs';
|
|
86
|
+
|
|
87
|
+
const jobQueue = createJobQueue({ adapter: 'bullmq', redisUrl: config.redis.url }, logger);
|
|
88
|
+
|
|
89
|
+
await jobQueue.enqueue('orders.process', { orderId: '123', userId: 'user-1' });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
For deduplication (prevent duplicate jobs for the same entity):
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
await jobQueue.enqueueWithDedup('orders.process', { orderId: '123', userId: 'user-1' }, {
|
|
96
|
+
dedupKey: 'orders.process:123',
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## 5. Test the Job
|
|
103
|
+
|
|
104
|
+
Use `TestJobQueue` from `@mariachi/testing`:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { describe, it, expect } from 'vitest';
|
|
108
|
+
import { TestJobQueue } from '@mariachi/testing';
|
|
109
|
+
import { ProcessOrderJob } from '../process-order.job';
|
|
110
|
+
|
|
111
|
+
describe('ProcessOrderJob', () => {
|
|
112
|
+
it('processes an order', async () => {
|
|
113
|
+
const queue = new TestJobQueue();
|
|
114
|
+
queue.registerJob(ProcessOrderJob);
|
|
115
|
+
await queue.enqueue('orders.process', { orderId: '123', userId: 'user-1' });
|
|
116
|
+
expect(queue.jobs).toHaveLength(1);
|
|
117
|
+
expect(queue.jobs[0].name).toBe('orders.process');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Worker Lifecycle
|
|
125
|
+
|
|
126
|
+
The worker connects to Redis (BullMQ) on startup and gracefully stops on shutdown:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
startup.register({
|
|
130
|
+
name: 'job-queue',
|
|
131
|
+
priority: 10,
|
|
132
|
+
fn: async () => {
|
|
133
|
+
await jobQueue.connect();
|
|
134
|
+
await jobQueue.start();
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
shutdown.register({
|
|
139
|
+
name: 'job-queue',
|
|
140
|
+
priority: 10,
|
|
141
|
+
fn: async () => {
|
|
142
|
+
await jobQueue.stop();
|
|
143
|
+
await jobQueue.disconnect();
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Checklist
|
|
151
|
+
|
|
152
|
+
- [ ] Job defined with Zod schema and retry config
|
|
153
|
+
- [ ] Job registered via `jobQueue.registerJob()` in worker entry point
|
|
154
|
+
- [ ] Schedule added (if cron-based)
|
|
155
|
+
- [ ] Enqueue call added in the service that triggers the job
|
|
156
|
+
- [ ] Tests added using `TestJobQueue`
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Recipe: Add a Domain Entity End-to-End
|
|
2
|
+
|
|
3
|
+
This walks through adding a new domain (e.g. `orders`) from schema to API endpoint. Each step references real patterns from the existing `users` domain.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Define the Schema
|
|
8
|
+
|
|
9
|
+
Create the table definition in `@mariachi/database`.
|
|
10
|
+
|
|
11
|
+
**File:** (in your app or shared package) e.g. `src/schema/orders.ts`
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { defineTable } from '@mariachi/database';
|
|
15
|
+
import { column } from '@mariachi/database';
|
|
16
|
+
|
|
17
|
+
export const ordersTable = defineTable('orders', {
|
|
18
|
+
id: column.uuid().primaryKey().defaultRandom(),
|
|
19
|
+
tenantId: column.text().notNull(),
|
|
20
|
+
userId: column.text().notNull(),
|
|
21
|
+
total: column.numeric().notNull(),
|
|
22
|
+
status: column.text().notNull(),
|
|
23
|
+
createdAt: column.timestamp().notNull().defaultNow(),
|
|
24
|
+
updatedAt: column.timestamp().notNull().defaultNow(),
|
|
25
|
+
deletedAt: column.timestamp(),
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 2. Compile to Drizzle
|
|
32
|
+
|
|
33
|
+
Add the compiled table for use with `@mariachi/database-postgres`.
|
|
34
|
+
|
|
35
|
+
**File:** e.g. `src/compiled-schemas.ts`
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { ordersTable } from './schema/orders';
|
|
39
|
+
import { compileTable } from '@mariachi/database-postgres';
|
|
40
|
+
export const orders = compileTable(ordersTable);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 3. Create the Repository
|
|
46
|
+
|
|
47
|
+
**File:** e.g. `src/repositories/orders.repository.ts`
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import type { Context } from '@mariachi/core';
|
|
51
|
+
import { DrizzleRepository } from '@mariachi/database-postgres';
|
|
52
|
+
import { orders } from '../compiled-schemas';
|
|
53
|
+
|
|
54
|
+
export interface Order {
|
|
55
|
+
id: string;
|
|
56
|
+
tenantId: string;
|
|
57
|
+
userId: string;
|
|
58
|
+
total: string;
|
|
59
|
+
status: string;
|
|
60
|
+
createdAt: Date;
|
|
61
|
+
updatedAt: Date;
|
|
62
|
+
deletedAt: Date | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class DrizzleOrdersRepository extends DrizzleRepository<Order> {
|
|
66
|
+
constructor(db: import('drizzle-orm/postgres-js').PostgresJsDatabase<Record<string, never>>) {
|
|
67
|
+
super(orders, db, { tenantColumn: 'tenantId' });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async findByUser(ctx: Context, userId: string): Promise<Order[]> {
|
|
71
|
+
return this.findMany(ctx, { userId } as Partial<Order>);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Inherited from `DrizzleRepository`: `findById`, `findMany`, `create`, `update`, `softDelete`, `hardDelete`, `paginate`, `count`.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 4. Create the Service
|
|
81
|
+
|
|
82
|
+
**File:** e.g. `src/orders/orders.service.ts` (in your services app)
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import type { Context } from '@mariachi/core';
|
|
86
|
+
import { z } from 'zod';
|
|
87
|
+
|
|
88
|
+
export const CreateOrderInput = z.object({
|
|
89
|
+
userId: z.string(),
|
|
90
|
+
total: z.string(),
|
|
91
|
+
status: z.string().default('pending'),
|
|
92
|
+
tenantId: z.string(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const GetOrderInput = z.object({
|
|
96
|
+
orderId: z.string(),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export const OrdersService = {
|
|
100
|
+
create: async (ctx: Context, input: z.infer<typeof CreateOrderInput>) => {
|
|
101
|
+
ctx.logger.info({ userId: input.userId }, 'Creating order');
|
|
102
|
+
const repo = new DrizzleOrdersRepository(db);
|
|
103
|
+
return repo.create(ctx, input);
|
|
104
|
+
},
|
|
105
|
+
getById: async (ctx: Context, input: z.infer<typeof GetOrderInput>) => {
|
|
106
|
+
ctx.logger.info({ orderId: input.orderId }, 'Fetching order');
|
|
107
|
+
const repo = new DrizzleOrdersRepository(db);
|
|
108
|
+
return repo.findById(ctx, input.orderId);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 5. Register Communication Handlers
|
|
116
|
+
|
|
117
|
+
**File:** e.g. `src/orders/orders.handler.ts`
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { OrdersService, CreateOrderInput, GetOrderInput } from './orders.service';
|
|
121
|
+
import { z } from 'zod';
|
|
122
|
+
|
|
123
|
+
const OrderOutput = z.object({
|
|
124
|
+
id: z.string(),
|
|
125
|
+
userId: z.string(),
|
|
126
|
+
total: z.string(),
|
|
127
|
+
status: z.string(),
|
|
128
|
+
tenantId: z.string(),
|
|
129
|
+
createdAt: z.date(),
|
|
130
|
+
updatedAt: z.date(),
|
|
131
|
+
deletedAt: z.date().nullable(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export function registerOrdersHandlers(communication: ReturnType<typeof createCommunication>) {
|
|
135
|
+
communication.register('orders.create', {
|
|
136
|
+
schema: { input: CreateOrderInput, output: OrderOutput },
|
|
137
|
+
handler: (ctx, input) => OrdersService.create(ctx, input),
|
|
138
|
+
});
|
|
139
|
+
communication.register('orders.getById', {
|
|
140
|
+
schema: { input: GetOrderInput, output: OrderOutput.nullable() },
|
|
141
|
+
handler: (ctx, input) => OrdersService.getById(ctx, input),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Wire into your aggregate registration (e.g. in your services app entry):
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { registerOrdersHandlers } from './orders/orders.handler';
|
|
150
|
+
|
|
151
|
+
export function registerServiceHandlers(communication) {
|
|
152
|
+
registerUsersHandlers(communication);
|
|
153
|
+
registerOrdersHandlers(communication);
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 6. Add the Controller
|
|
160
|
+
|
|
161
|
+
**File:** e.g. `src/controllers/orders.controller.ts` (in your API app)
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { z } from 'zod';
|
|
165
|
+
import { BaseController, type HttpContext } from '@mariachi/api-facade';
|
|
166
|
+
import { createCommunication } from '@mariachi/communication';
|
|
167
|
+
|
|
168
|
+
const CreateOrderInput = z.object({
|
|
169
|
+
userId: z.string(),
|
|
170
|
+
total: z.string(),
|
|
171
|
+
status: z.string().optional(),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const GetOrderParams = z.object({
|
|
175
|
+
orderId: z.string(),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const communication = createCommunication();
|
|
179
|
+
|
|
180
|
+
export class OrdersController extends BaseController {
|
|
181
|
+
readonly prefix = 'orders';
|
|
182
|
+
|
|
183
|
+
init() {
|
|
184
|
+
this.post(this.buildPath(), this.create);
|
|
185
|
+
this.get(this.buildPath(':id'), this.getById);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
create = async (ctx: HttpContext, body: unknown) => {
|
|
189
|
+
const input = CreateOrderInput.parse(body);
|
|
190
|
+
return communication.call('orders.create', ctx, input);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
getById = async (ctx: HttpContext, _body: unknown, params: Record<string, string>) => {
|
|
194
|
+
const input = GetOrderParams.parse({ orderId: params.id });
|
|
195
|
+
return communication.call('orders.getById', ctx, input);
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Register on your server:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import { OrdersController } from './controllers/orders.controller';
|
|
204
|
+
|
|
205
|
+
const publicServer = createPublicServer()
|
|
206
|
+
.registerController(new UsersController())
|
|
207
|
+
.registerController(new OrdersController());
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## 7. Add Tests
|
|
213
|
+
|
|
214
|
+
**File:** e.g. `src/orders/test/orders.service.test.ts`
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
import { describe, it, expect } from 'vitest';
|
|
218
|
+
import { createTestContext } from '@mariachi/testing';
|
|
219
|
+
import { OrdersService } from '../orders.service';
|
|
220
|
+
|
|
221
|
+
describe('OrdersService', () => {
|
|
222
|
+
it('creates an order', async () => {
|
|
223
|
+
const ctx = createTestContext();
|
|
224
|
+
const result = await OrdersService.create(ctx, {
|
|
225
|
+
userId: 'user-1',
|
|
226
|
+
total: '99.99',
|
|
227
|
+
status: 'pending',
|
|
228
|
+
tenantId: 'tenant-1',
|
|
229
|
+
});
|
|
230
|
+
expect(result.id).toBeDefined();
|
|
231
|
+
expect(result.userId).toBe('user-1');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Checklist
|
|
239
|
+
|
|
240
|
+
- [ ] Schema defined and exported
|
|
241
|
+
- [ ] Compiled table added for Drizzle
|
|
242
|
+
- [ ] Repository created extending `DrizzleRepository`
|
|
243
|
+
- [ ] Service created in services app
|
|
244
|
+
- [ ] Handler registered via `communication.register()`
|
|
245
|
+
- [ ] Handler wired into `registerServiceHandlers()`
|
|
246
|
+
- [ ] Controller created in API app
|
|
247
|
+
- [ ] Controller registered on server
|
|
248
|
+
- [ ] Tests added
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Recipe: Add a Third-Party Integration
|
|
2
|
+
|
|
3
|
+
This walks through adding a new integration (e.g. GitHub, Twilio) using the `@mariachi/integrations` pattern.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Structure
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
integrations/<name>/
|
|
11
|
+
├── index.ts # Integration functions (defineIntegrationFn)
|
|
12
|
+
├── credentials.ts # Zod-validated credential schema
|
|
13
|
+
├── client.ts # Raw API client (fetch calls)
|
|
14
|
+
├── types.ts # Input/output Zod schemas
|
|
15
|
+
└── test.ts # Dry-run test
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 1. Define Credentials
|
|
21
|
+
|
|
22
|
+
Every integration has a Zod schema for its credentials. Never hardcode secrets.
|
|
23
|
+
|
|
24
|
+
**File:** `integrations/<name>/credentials.ts`
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
|
|
29
|
+
export const GitHubCredentials = z.object({
|
|
30
|
+
token: z.string().min(1),
|
|
31
|
+
webhookSecret: z.string().min(1),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type GitHubCredentials = z.infer<typeof GitHubCredentials>;
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Store actual values via `@mariachi/config` (env variables), not in code.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 2. Define Input/Output Types
|
|
42
|
+
|
|
43
|
+
Use Zod schemas for all inputs and outputs.
|
|
44
|
+
|
|
45
|
+
**File:** `integrations/<name>/types.ts`
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { z } from 'zod';
|
|
49
|
+
|
|
50
|
+
export const CreateIssueInput = z.object({
|
|
51
|
+
owner: z.string().min(1),
|
|
52
|
+
repo: z.string().min(1),
|
|
53
|
+
title: z.string().min(1),
|
|
54
|
+
body: z.string().optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const CreateIssueOutput = z.object({
|
|
58
|
+
id: z.number(),
|
|
59
|
+
number: z.number(),
|
|
60
|
+
url: z.string(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export type CreateIssueInput = z.infer<typeof CreateIssueInput>;
|
|
64
|
+
export type CreateIssueOutput = z.infer<typeof CreateIssueOutput>;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 3. Implement the Client
|
|
70
|
+
|
|
71
|
+
The client is a thin wrapper around the external API.
|
|
72
|
+
|
|
73
|
+
**File:** `integrations/<name>/client.ts`
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import type { GitHubCredentials } from './credentials';
|
|
77
|
+
import type { CreateIssueInput, CreateIssueOutput } from './types';
|
|
78
|
+
|
|
79
|
+
export async function createIssue(
|
|
80
|
+
credentials: GitHubCredentials,
|
|
81
|
+
input: CreateIssueInput,
|
|
82
|
+
): Promise<CreateIssueOutput> {
|
|
83
|
+
const url = `https://api.github.com/repos/${input.owner}/${input.repo}/issues`;
|
|
84
|
+
|
|
85
|
+
const res = await fetch(url, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: {
|
|
88
|
+
'Content-Type': 'application/json',
|
|
89
|
+
Authorization: `Bearer ${credentials.token}`,
|
|
90
|
+
Accept: 'application/vnd.github+json',
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({ title: input.title, body: input.body }),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
throw new Error(`GitHub API error: ${res.status} ${await res.text()}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const data = (await res.json()) as { id: number; number: number; html_url: string };
|
|
100
|
+
return { id: data.id, number: data.number, url: data.html_url };
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 4. Define the Integration Function
|
|
107
|
+
|
|
108
|
+
Use `defineIntegrationFn` from `@mariachi/integrations`.
|
|
109
|
+
|
|
110
|
+
**File:** `integrations/<name>/index.ts`
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { defineIntegrationFn } from '@mariachi/integrations';
|
|
114
|
+
import type { IntegrationContext } from '@mariachi/integrations';
|
|
115
|
+
import { createIssue } from './client';
|
|
116
|
+
import type { GitHubCredentials } from './credentials';
|
|
117
|
+
import { CreateIssueInput, CreateIssueOutput } from './types';
|
|
118
|
+
|
|
119
|
+
export interface GitHubIntegrationContext extends IntegrationContext {
|
|
120
|
+
credentials: GitHubCredentials;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const githubCreateIssue = defineIntegrationFn<CreateIssueInput, CreateIssueOutput>({
|
|
124
|
+
name: 'github.createIssue',
|
|
125
|
+
input: CreateIssueInput,
|
|
126
|
+
output: CreateIssueOutput,
|
|
127
|
+
handler: async (input: CreateIssueInput, ctx: IntegrationContext): Promise<CreateIssueOutput> => {
|
|
128
|
+
const credentials = (ctx as GitHubIntegrationContext).credentials;
|
|
129
|
+
if (!credentials) {
|
|
130
|
+
throw new Error('GitHub credentials required');
|
|
131
|
+
}
|
|
132
|
+
return createIssue(credentials, input);
|
|
133
|
+
},
|
|
134
|
+
retry: { attempts: 3, backoff: 'exponential' },
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**Key fields:**
|
|
139
|
+
- `name` — unique identifier, conventionally `<provider>.<action>`
|
|
140
|
+
- `input` / `output` — Zod schemas for validation
|
|
141
|
+
- `handler` — receives validated input and `IntegrationContext` (with credentials)
|
|
142
|
+
- `retry` — optional retry config
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## 5. Register in the Registry (Optional)
|
|
147
|
+
|
|
148
|
+
For discoverability, register the integration in a central registry.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { IntegrationRegistry } from '@mariachi/integrations';
|
|
152
|
+
import { GitHubCredentials } from './credentials';
|
|
153
|
+
|
|
154
|
+
const registry = new IntegrationRegistry();
|
|
155
|
+
registry.register({
|
|
156
|
+
name: 'github',
|
|
157
|
+
description: 'GitHub integration for issues and repositories',
|
|
158
|
+
credentialSchema: GitHubCredentials,
|
|
159
|
+
functions: ['github.createIssue'],
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## 6. Add a Test
|
|
166
|
+
|
|
167
|
+
**File:** `integrations/<name>/test.ts`
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { CreateIssueInput } from './types';
|
|
171
|
+
import { GitHubCredentials } from './credentials';
|
|
172
|
+
|
|
173
|
+
export async function dryRun(
|
|
174
|
+
credentials: GitHubCredentials,
|
|
175
|
+
input: { owner: string; repo: string; title: string; body?: string },
|
|
176
|
+
): Promise<{ ok: true; id: number; number: number; url: string }> {
|
|
177
|
+
GitHubCredentials.parse(credentials);
|
|
178
|
+
CreateIssueInput.parse(input);
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
id: 12345,
|
|
182
|
+
number: 1,
|
|
183
|
+
url: `https://github.com/${input.owner}/${input.repo}/issues/1`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Checklist
|
|
191
|
+
|
|
192
|
+
- [ ] Credentials schema defined in `credentials.ts` (Zod)
|
|
193
|
+
- [ ] Input/output types defined in `types.ts` (Zod)
|
|
194
|
+
- [ ] Client function implemented in `client.ts`
|
|
195
|
+
- [ ] Integration function defined with `defineIntegrationFn` in `index.ts`
|
|
196
|
+
- [ ] Retry config set appropriately
|
|
197
|
+
- [ ] Dry-run test in `test.ts`
|
|
198
|
+
- [ ] Secrets stored via env/config, never hardcoded
|