@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.
@@ -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