* MCP Server 2.0 - Updated application version from 2.23.14 to 2.24.0 in package.json. - MCP Server improved with new features - Migrated functionality from Dev Tools to MCP Server - Improved getMonitoringProvider not to crash application when misconfigured
274 lines
7.2 KiB
Markdown
274 lines
7.2 KiB
Markdown
# Service Builder Examples
|
|
|
|
## Service with Multiple Dependencies
|
|
|
|
When a service needs more than just a database client, inject all dependencies.
|
|
|
|
```typescript
|
|
// _lib/server/invoice.service.ts
|
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
|
|
interface InvoiceServiceDeps {
|
|
client: SupabaseClient;
|
|
storage: SupabaseClient['storage'];
|
|
}
|
|
|
|
export function createInvoiceService(deps: InvoiceServiceDeps) {
|
|
return new InvoiceService(deps);
|
|
}
|
|
|
|
class InvoiceService {
|
|
constructor(private readonly deps: InvoiceServiceDeps) {}
|
|
|
|
async generatePdf(invoiceId: string): Promise<{ url: string }> {
|
|
const { data: invoice, error } = await this.deps.client
|
|
.from('invoices')
|
|
.select('*')
|
|
.eq('id', invoiceId)
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
|
|
const pdf = this.buildPdf(invoice);
|
|
|
|
const { data: upload, error: uploadError } = await this.deps.storage
|
|
.from('invoices')
|
|
.upload(`${invoiceId}.pdf`, pdf);
|
|
|
|
if (uploadError) throw uploadError;
|
|
|
|
return { url: upload.path };
|
|
}
|
|
|
|
private buildPdf(invoice: Record<string, unknown>): Uint8Array {
|
|
// Pure logic — no I/O
|
|
// ...
|
|
return new Uint8Array();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Server action adapter:**
|
|
|
|
```typescript
|
|
'use server';
|
|
|
|
import { enhanceAction } from '@kit/next/actions';
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
|
|
import { GenerateInvoiceSchema } from '../schemas/invoice.schema';
|
|
import { createInvoiceService } from './invoice.service';
|
|
|
|
export const generateInvoicePdfAction = enhanceAction(
|
|
async function (data, user) {
|
|
const client = getSupabaseServerClient();
|
|
const service = createInvoiceService({
|
|
client,
|
|
storage: client.storage,
|
|
});
|
|
|
|
return service.generatePdf(data.invoiceId);
|
|
},
|
|
{ auth: true, schema: GenerateInvoiceSchema },
|
|
);
|
|
```
|
|
|
|
## Service Composing Other Services
|
|
|
|
Services can depend on other services — compose at the adapter level.
|
|
|
|
```typescript
|
|
// _lib/server/onboarding.service.ts
|
|
import type { ProjectService } from './project.service';
|
|
import type { NotificationService } from './notification.service';
|
|
|
|
interface OnboardingServiceDeps {
|
|
projects: ProjectService;
|
|
notifications: NotificationService;
|
|
}
|
|
|
|
export function createOnboardingService(deps: OnboardingServiceDeps) {
|
|
return new OnboardingService(deps);
|
|
}
|
|
|
|
class OnboardingService {
|
|
constructor(private readonly deps: OnboardingServiceDeps) {}
|
|
|
|
async onboardAccount(params: { accountId: string; accountName: string }) {
|
|
// Create default project
|
|
const project = await this.deps.projects.create({
|
|
name: `${params.accountName}'s First Project`,
|
|
accountId: params.accountId,
|
|
});
|
|
|
|
// Send welcome notification
|
|
await this.deps.notifications.send({
|
|
accountId: params.accountId,
|
|
type: 'welcome',
|
|
data: { projectId: project.id },
|
|
});
|
|
|
|
return { project };
|
|
}
|
|
}
|
|
```
|
|
|
|
**Adapter composes the dependency tree:**
|
|
|
|
```typescript
|
|
'use server';
|
|
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
|
|
import { createOnboardingService } from './onboarding.service';
|
|
import { createProjectService } from './project.service';
|
|
import { createNotificationService } from './notification.service';
|
|
|
|
export const onboardAccountAction = enhanceAction(
|
|
async function (data, user) {
|
|
const client = getSupabaseServerClient();
|
|
|
|
const service = createOnboardingService({
|
|
projects: createProjectService(client),
|
|
notifications: createNotificationService(client),
|
|
});
|
|
|
|
return service.onboardAccount(data);
|
|
},
|
|
{ auth: true, schema: OnboardAccountSchema },
|
|
);
|
|
```
|
|
|
|
## Pure Validation Service (No I/O)
|
|
|
|
Some services are entirely pure — they don't even need a database client.
|
|
|
|
```typescript
|
|
// _lib/server/pricing.service.ts
|
|
|
|
interface PricingInput {
|
|
plan: 'starter' | 'pro' | 'enterprise';
|
|
seats: number;
|
|
billingPeriod: 'monthly' | 'yearly';
|
|
}
|
|
|
|
interface PricingResult {
|
|
unitPrice: number;
|
|
total: number;
|
|
discount: number;
|
|
currency: string;
|
|
}
|
|
|
|
export function calculatePricing(input: PricingInput): PricingResult {
|
|
const basePrices = { starter: 900, pro: 2900, enterprise: 9900 };
|
|
const unitPrice = basePrices[input.plan];
|
|
const yearlyDiscount = input.billingPeriod === 'yearly' ? 0.2 : 0;
|
|
const seatDiscount = input.seats >= 10 ? 0.1 : 0;
|
|
const discount = Math.min(yearlyDiscount + seatDiscount, 0.3);
|
|
const total = Math.round(unitPrice * input.seats * (1 - discount));
|
|
|
|
return { unitPrice, total, discount, currency: 'usd' };
|
|
}
|
|
```
|
|
|
|
This is the simplest case — a plain function, no class, no dependencies. Trivially testable:
|
|
|
|
```typescript
|
|
import { calculatePricing } from '../pricing.service';
|
|
|
|
it('applies yearly discount', () => {
|
|
const result = calculatePricing({
|
|
plan: 'pro',
|
|
seats: 1,
|
|
billingPeriod: 'yearly',
|
|
});
|
|
|
|
expect(result.discount).toBe(0.2);
|
|
expect(result.total).toBe(2320); // 2900 * 0.8
|
|
});
|
|
```
|
|
|
|
## Testing with Mock Client
|
|
|
|
Full mock pattern for Supabase client:
|
|
|
|
```typescript
|
|
import { vi } from 'vitest';
|
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
|
|
/**
|
|
* Creates a chainable mock that mimics Supabase's query builder.
|
|
* Override any method in the chain via the `overrides` param.
|
|
*/
|
|
export function createMockSupabaseClient(
|
|
resolvedValue: { data: unknown; error: unknown } = { data: null, error: null },
|
|
overrides: Record<string, unknown> = {},
|
|
) {
|
|
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
|
|
|
|
// Every method returns `this` (chainable) by default
|
|
const methods = [
|
|
'select', 'insert', 'update', 'upsert', 'delete',
|
|
'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in',
|
|
'like', 'ilike', 'is', 'order', 'limit', 'range',
|
|
'single', 'maybeSingle',
|
|
];
|
|
|
|
for (const method of methods) {
|
|
chain[method] = vi.fn().mockReturnThis();
|
|
}
|
|
|
|
// Terminal methods resolve with data
|
|
chain.single = vi.fn().mockResolvedValue(resolvedValue);
|
|
chain.maybeSingle = vi.fn().mockResolvedValue(resolvedValue);
|
|
|
|
// Apply overrides
|
|
for (const [key, value] of Object.entries(overrides)) {
|
|
chain[key] = vi.fn().mockImplementation(
|
|
typeof value === 'function' ? value : () => value,
|
|
);
|
|
}
|
|
|
|
// Non-terminal chains that don't end with single/maybeSingle
|
|
// resolve when awaited via .then()
|
|
const proxyHandler: ProxyHandler<typeof chain> = {
|
|
get(target, prop) {
|
|
if (prop === 'then') {
|
|
return (resolve: (v: unknown) => void) => resolve(resolvedValue);
|
|
}
|
|
return target[prop as string] ?? vi.fn().mockReturnValue(target);
|
|
},
|
|
};
|
|
|
|
const chainProxy = new Proxy(chain, proxyHandler);
|
|
|
|
return {
|
|
from: vi.fn(() => chainProxy),
|
|
chain,
|
|
} as unknown as SupabaseClient & { chain: typeof chain };
|
|
}
|
|
```
|
|
|
|
Usage:
|
|
|
|
```typescript
|
|
import { createMockSupabaseClient } from '../test-utils';
|
|
import { createProjectService } from '../project.service';
|
|
|
|
it('lists projects for an account', async () => {
|
|
const projects = [
|
|
{ id: '1', name: 'Alpha', account_id: 'acc-1' },
|
|
{ id: '2', name: 'Beta', account_id: 'acc-1' },
|
|
];
|
|
|
|
const client = createMockSupabaseClient({ data: projects, error: null });
|
|
const service = createProjectService(client);
|
|
|
|
const result = await service.list('acc-1');
|
|
|
|
expect(result).toEqual(projects);
|
|
expect(client.from).toHaveBeenCalledWith('projects');
|
|
expect(client.chain.eq).toHaveBeenCalledWith('account_id', 'acc-1');
|
|
});
|
|
```
|