Skip to main content

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

  1. 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
}
  1. Create migration:
npx prisma migrate dev --name add_annotation_notes
  1. 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

  1. Add to schema.prisma:
model MyModel {
id String @id @default(uuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
  1. Create migration:
npx prisma migrate dev --name add_my_model
  1. 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();
});

Next Steps