6.6 KiB
Server Shared Package
This package is the API contract layer for the Buster monorepo. ALL request and response types for server communication live here.
Core Responsibility
@buster/server-shared
serves as the single source of truth for:
- API request schemas and types
- API response schemas and types
- Shared validation logic
- Type flow from database to clients
Type Flow Architecture
@buster/database → @buster/server-shared → Apps (web, cli, etc.)
Types cascade through server-shared to ensure consistency:
- Database types are imported into server-shared
- Server-shared exports API contracts based on database types
- Apps import from server-shared, never directly from database
Implementation Patterns
Zod Schema Definition
Every type MUST be defined as a Zod schema first with descriptions:
import { z } from 'zod';
// Define schema with descriptions
export const CreateUserRequestSchema = z.object({
email: z.string().email().describe('User email address'),
name: z.string().min(1).describe('User full name'),
orgId: z.string().uuid().describe('Organization identifier'),
role: z.enum(['admin', 'member', 'viewer']).describe('User role in organization')
});
// Export inferred type
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
Importing Database Types
Always import database types as type-only imports:
// ✅ Correct: Type-only import
import type { User, Organization } from '@buster/database';
// ❌ Wrong: Value import (causes build failures)
import { User, Organization } from '@buster/database';
Response Type Patterns
Responses should cascade database types through Zod schemas:
import { z } from 'zod';
import type { User } from '@buster/database';
// Define response schema
export const GetUserResponseSchema = z.object({
user: z.custom<User>().describe('User object from database'),
permissions: z.array(z.string()).describe('User permissions'),
workspace: z.object({
id: z.string(),
name: z.string(),
plan: z.enum(['free', 'pro', 'enterprise'])
}).describe('User workspace details')
});
export type GetUserResponse = z.infer<typeof GetUserResponseSchema>;
Enum Patterns
Use const assertions for type-safe string literals:
export const UserRole = {
ADMIN: 'admin',
MEMBER: 'member',
VIEWER: 'viewer'
} as const;
export type UserRole = typeof UserRole[keyof typeof UserRole];
// Use in schemas
export const UserSchema = z.object({
role: z.enum(['admin', 'member', 'viewer'])
});
File Organization
server-shared/
├── src/
│ ├── users/
│ │ ├── requests.ts # User request schemas
│ │ ├── responses.ts # User response schemas
│ │ └── index.ts # Barrel export
│ ├── chats/
│ │ ├── requests.ts
│ │ ├── responses.ts
│ │ └── index.ts
│ ├── security/
│ │ ├── requests.ts
│ │ ├── responses.ts
│ │ └── index.ts
│ └── index.ts # Main barrel export
Validation Patterns
Request Validation
Use .parse()
for trusted data, .safeParse()
for user input:
// In server endpoint
export async function createUser(data: unknown) {
// Safe parse for user input
const result = CreateUserRequestSchema.safeParse(data);
if (!result.success) {
throw new ValidationError(result.error);
}
// Use validated data
const user = await createUserInDb(result.data);
return user;
}
Type-Only Validation
When TypeScript types are sufficient, avoid runtime parsing:
// If data is already typed from a trusted source
export async function processUser(user: User) {
// No need for runtime validation
// TypeScript ensures type safety
return transformUser(user);
}
Best Practices
DO:
- Define ALL API types as Zod schemas with descriptions
- Export both schema and inferred type
- Import database types as type-only
- Use const assertions for string literals
- Organize by feature/domain
- Validate at API boundaries
DON'T:
- Import database package as values
- Define types without Zod schemas
- Use
.parse()
unnecessarily when types are sufficient - Create circular dependencies
- Mix request/response types in same file
Common Patterns
Paginated Responses
export const PaginatedResponseSchema = <T extends z.ZodType>(itemSchema: T) =>
z.object({
items: z.array(itemSchema),
total: z.number().describe('Total number of items'),
page: z.number().describe('Current page number'),
pageSize: z.number().describe('Items per page'),
hasMore: z.boolean().describe('Whether more pages exist')
});
// Usage
export const GetUsersResponseSchema = PaginatedResponseSchema(UserSchema);
Error Responses
export const ErrorResponseSchema = z.object({
error: z.object({
code: z.string().describe('Error code'),
message: z.string().describe('Human-readable error message'),
details: z.record(z.unknown()).optional().describe('Additional error context')
})
});
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
Success Responses
export const SuccessResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
z.object({
success: z.literal(true),
data: dataSchema
});
// Usage
export const CreateUserResponseSchema = SuccessResponseSchema(UserSchema);
Testing
Test schemas with various inputs to ensure validation works correctly:
describe('CreateUserRequestSchema', () => {
it('should validate valid user data', () => {
const data = {
email: 'test@example.com',
name: 'Test User',
orgId: '123e4567-e89b-12d3-a456-426614174000',
role: 'member'
};
const result = CreateUserRequestSchema.safeParse(data);
expect(result.success).toBe(true);
});
it('should reject invalid email', () => {
const data = {
email: 'not-an-email',
name: 'Test User',
orgId: '123e4567-e89b-12d3-a456-426614174000',
role: 'member'
};
const result = CreateUserRequestSchema.safeParse(data);
expect(result.success).toBe(false);
});
});
Integration with Apps
Apps should only import from server-shared:
// In apps/web
import type { CreateUserRequest, GetUserResponse } from '@buster/server-shared';
// In apps/cli
import { CreateUserRequestSchema } from '@buster/server-shared';
// Never do this in apps:
import type { User } from '@buster/database'; // ❌ Wrong
This ensures all apps use consistent types and validation.