Backend Development
The backend service provides REST APIs for video annotation, ontology management, and world state persistence. Built with Node.js 22, Fastify 5, and TypeScript 5.3+, it uses Prisma 6 for PostgreSQL access and BullMQ 5 for job queues.
Development Environment
Prerequisites
- Node.js 22 LTS
- PostgreSQL 16
- Redis 7
- Docker (optional, for infrastructure)
Initial Setup
cd server
npm install
npx prisma generate
Configuration
Create .env
file:
DATABASE_URL="postgresql://fovea:fovea_password@localhost:5432/fovea"
REDIS_URL="redis://localhost:6379"
PORT=3001
NODE_ENV=development
Start Development Server
npm run dev
Server starts at http://localhost:3001
with hot reload via tsx watch
.
Project Structure
server/
├── src/
│ ├── index.ts # Entry point, server initialization
│ ├── app.ts # Fastify app configuration
│ ├── tracing.ts # OpenTelemetry setup (must be first import)
│ ├── routes/ # API route handlers
│ │ ├── videos.ts
│ │ ├── personas.ts
│ │ ├── ontology.ts
│ │ ├── world.ts
│ │ ├── annotations.ts
│ │ └── export.ts
│ ├── services/ # Business logic
│ │ ├── videoService.ts
│ │ ├── ontologyService.ts
│ │ ├── worldService.ts
│ │ └── importService.ts
│ ├── queues/ # BullMQ job processors
│ │ ├── videoQueue.ts
│ │ └── summarizationWorker.ts
│ ├── models/ # Prisma client, types
│ │ └── index.ts
│ ├── utils/ # Helper functions
│ │ ├── validation.ts
│ │ └── errors.ts
│ └── metrics.ts # Custom metrics
├── prisma/
│ └── schema.prisma # Database schema
└── test/ # Test files
├── routes/
└── services/
Running the Backend
Development Mode
npm run dev
Uses tsx watch
for automatic restart on file changes.
Production Build
npm run build # Compile TypeScript to dist/
npm run start # Run compiled server
Testing
npm run test # Run all tests
npm run test:coverage # Run with coverage
Linting
npm run lint # Check code style
Database Workflow
Schema Changes
- Edit
prisma/schema.prisma
:
model Annotation {
id String @id @default(uuid())
videoId String
personaId String
// Add new field
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
- Create migration:
npx prisma migrate dev --name add_annotation_notes
- Generate Prisma client:
npx prisma generate
Database GUI
Open Prisma Studio:
npx prisma studio
Access at http://localhost:5555
to browse and edit data.
Reset Database
npx prisma migrate reset # WARNING: Deletes all data
npx prisma migrate deploy # Apply migrations only
Adding New API Routes
Step 1: Define Route Handler
Create src/routes/myResource.ts
:
import { FastifyInstance } from 'fastify';
import { Type } from '@sinclair/typebox';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
const MyItemSchema = Type.Object({
id: Type.String(),
name: Type.String(),
description: Type.Optional(Type.String())
});
export async function myResourceRoutes(fastify: FastifyInstance) {
const server = fastify.withTypeProvider<TypeBoxTypeProvider>();
server.get('/api/my-resource', {
schema: {
response: {
200: Type.Array(MyItemSchema)
}
}
}, async (request, reply) => {
const items = await fastify.prisma.myItem.findMany();
return items;
});
server.post('/api/my-resource', {
schema: {
body: Type.Object({
name: Type.String(),
description: Type.Optional(Type.String())
}),
response: {
201: MyItemSchema
}
}
}, async (request, reply) => {
const item = await fastify.prisma.myItem.create({
data: request.body
});
reply.code(201);
return item;
});
}
Step 2: Register Route
In src/app.ts
:
import { myResourceRoutes } from './routes/myResource.js';
export async function buildApp() {
const app = fastify();
// ... existing routes
await app.register(myResourceRoutes);
return app;
}
Step 3: Add Tests
Create test/routes/myResource.test.ts
:
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '../../src/app.js';
describe('My Resource Routes', () => {
let app;
beforeAll(async () => {
app = await buildApp();
});
afterAll(async () => {
await app.close();
});
it('GET /api/my-resource returns items', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/my-resource'
});
expect(response.statusCode).toBe(200);
expect(Array.isArray(response.json())).toBe(true);
});
});
Background Jobs with BullMQ
Step 1: Define Queue
Create src/queues/myQueue.ts
:
import { Queue } from 'bullmq';
import Redis from 'ioredis';
const connection = new Redis(process.env.REDIS_URL);
export const myQueue = new Queue('my-queue', { connection });
export async function addMyJob(data: { id: string; params: unknown }) {
await myQueue.add('process-item', data, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000
}
});
}
Step 2: Create Worker
Create src/queues/myWorker.ts
:
import { Worker, Job } from 'bullmq';
import Redis from 'ioredis';
const connection = new Redis(process.env.REDIS_URL);
export const myWorker = new Worker('my-queue', async (job: Job) => {
console.log(`Processing job ${job.id}`);
// Job logic here
const result = await processItem(job.data);
return result;
}, { connection });
myWorker.on('completed', (job) => {
console.log(`Job ${job.id} completed`);
});
myWorker.on('failed', (job, err) => {
console.error(`Job ${job.id} failed:`, err);
});
async function processItem(data: any) {
// Implementation
return { success: true };
}
Step 3: Start Worker
In separate terminal:
node dist/queues/myWorker.js
Or add to package.json
:
{
"scripts": {
"worker": "tsx src/queues/myWorker.ts"
}
}
Error Handling
Custom Error Classes
export class NotFoundError extends Error {
statusCode = 404;
constructor(message: string) {
super(message);
this.name = 'NotFoundError';
}
}
export class ValidationError extends Error {
statusCode = 400;
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
Error Handler
fastify.setErrorHandler((error, request, reply) => {
request.log.error(error);
if (error.statusCode) {
reply.code(error.statusCode).send({
error: error.name,
message: error.message
});
} else {
reply.code(500).send({
error: 'Internal Server Error',
message: 'An unexpected error occurred'
});
}
});
OpenTelemetry and Metrics
Add Custom Metric
In src/metrics.ts
:
import { metrics } from '@opentelemetry/api';
const meter = metrics.getMeter('fovea-backend');
export const myCounter = meter.createCounter('fovea_my_operation_total', {
description: 'Total number of my operations',
unit: '1'
});
export const myHistogram = meter.createHistogram('fovea_my_operation_duration', {
description: 'Duration of my operations',
unit: 'ms'
});
Use in Route
import { myCounter, myHistogram } from '../metrics.js';
server.post('/api/my-operation', async (request, reply) => {
const start = Date.now();
try {
const result = await performOperation();
myCounter.add(1, { status: 'success' });
return result;
} catch (error) {
myCounter.add(1, { status: 'error' });
throw error;
} finally {
myHistogram.record(Date.now() - start);
}
});
Debugging
VS Code Configuration
Create .vscode/launch.json
:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Backend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"skipFiles": ["<node_internals>/**"],
"envFile": "${workspaceFolder}/server/.env"
}
]
}
Logging
Use Fastify logger:
fastify.log.info('Info message');
fastify.log.error({ err: error }, 'Error occurred');
fastify.log.debug({ data }, 'Debug data');
Logs include trace context automatically.
Common Development Tasks
Add New Prisma Model
- Add to
schema.prisma
:
model MyModel {
id String @id @default(uuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
- Create migration:
npx prisma migrate dev --name add_my_model
- Use in code:
const items = await fastify.prisma.myModel.findMany();
Add Request Validation
Use TypeBox schemas:
import { Type } from '@sinclair/typebox';
const CreateSchema = Type.Object({
name: Type.String({ minLength: 1, maxLength: 100 }),
email: Type.String({ format: 'email' }),
age: Type.Optional(Type.Integer({ minimum: 0, maximum: 120 }))
});
server.post('/api/users', {
schema: {
body: CreateSchema
}
}, async (request, reply) => {
// request.body is validated and typed
const user = await createUser(request.body);
return user;
});
Add Middleware
fastify.addHook('preHandler', async (request, reply) => {
// Check authentication
const token = request.headers.authorization;
if (!token) {
reply.code(401).send({ error: 'Unauthorized' });
}
});
Troubleshooting
Port Already in Use
lsof -i :3001 # Find process using port
kill -9 <PID> # Kill process
Or change port in .env
:
PORT=3002
Database Connection Fails
Check PostgreSQL is running:
docker compose ps postgres
docker compose logs postgres
Verify connection string in .env
.
Prisma Client Not Found
Regenerate client:
npx prisma generate
Hot Reload Not Working
Restart dev server:
npm run dev
Check package.json
uses tsx watch
.
Testing Best Practices
Test Structure
describe('User Service', () => {
describe('createUser', () => {
it('creates user with valid data', async () => {
const user = await createUser({ name: 'Test' });
expect(user.name).toBe('Test');
});
it('throws error for invalid data', async () => {
await expect(createUser({ name: '' }))
.rejects.toThrow('Name required');
});
});
});
Database in Tests
Use test database:
beforeAll(async () => {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
await prisma.$connect();
});
afterAll(async () => {
await prisma.$disconnect();
});
afterEach(async () => {
// Clean up test data
await prisma.user.deleteMany();
});