Guide for Developers
Technical guide for engineers building on the Loop Health platform.
Development Workflow
Day-to-Day Commands
# Start development (all apps)
pnpm dev
# Start a specific app
pnpm --filter @loop/admin dev
pnpm --filter @loop/my-loop-health dev
pnpm --filter @loop/luna dev
pnpm --filter @loop/patient-graph-api dev
pnpm --filter @loop/docs dev
# Type-check everything
pnpm typecheck
# Lint
pnpm lint
# Run tests
pnpm test
# Build all
pnpm buildAdding a Dependency
# To a specific package
pnpm --filter @loop/core add zod
# Dev dependency
pnpm --filter @loop/core add -D vitest
# To the workspace root
pnpm add -w turborepoCreating a New Package
- Create directory:
packages/my-package/ - Add
package.jsonwith"name": "@loop/my-package" - Add
tsconfig.jsonextending@loop/tsconfig/node.json - Export from
src/index.ts - Add to consuming packages’
package.jsonas"@loop/my-package": "workspace:*"
Code Patterns
Result Type (Error Handling)
Use Result<T> from @loop/core instead of try/catch:
import { ok, err, tryCatch, type Result } from '@loop/core';
// Return ok or err
function divide(a: number, b: number): Result<number> {
if (b === 0) return err(createError('VALIDATION', 'Cannot divide by zero'));
return ok(a / b);
}
// Wrap throwing code
const result = await tryCatch(() => fetch('/api/data'));
if (result.ok) {
console.log(result.value);
} else {
console.error(result.error);
}
// Chain with map/flatMap
const doubled = map(divide(10, 2), (n) => n * 2);Logging
Use createLogger() from @loop/core — never console.*:
import { createLogger } from '@loop/core';
const logger = createLogger('my-service');
logger.info('Processing request', { userId: '123' });
logger.error('Failed to process', { error });In development, logs are pretty-printed. In production, they’re JSON for ingestion.
Zod Validation
Schemas are centralized in @loop/shared:
import { z } from 'zod';
// Define schema
export const createProfileSchema = z.object({
email: z.string().email(),
firstName: z.string().min(1),
lastName: z.string().min(1),
biologicalSex: z.enum(['male', 'female', 'other']),
});
// Use in routes
app.post('/profiles', zValidator('json', createProfileSchema), async (c) => {
const data = c.req.valid('json');
// data is fully typed
});Repository Pattern
Database access uses the repository pattern:
import { createRepositories } from '@loop/patient-graph';
const repos = createRepositories(connection);
// Type-safe CRUD
const profiles = await repos.profiles.list({ limit: 20 });
const profile = await repos.profiles.findById(id);
const created = await repos.profiles.create(data);
await repos.profiles.update(id, patch);
await repos.profiles.delete(id);Hono Middleware
Use @loop/hono for consistent middleware:
import { createApp } from '@loop/hono';
const app = createApp();
// Auth middleware
app.use('/*', requireAuth);
// Role-based access
app.use('/admin/*', requireRole('admin'));
// Rate limiting
app.use('/*', rateLimit({ max: 100, window: 60 }));
// Response helpers
app.get('/users', async (c) => {
const users = await getUsers();
return success(c, users);
});Working with Packages
Import Convention
Always use the @loop/* namespace:
import { ok, err, createLogger } from '@loop/core';
import { checkAccess } from '@loop/shared/rbac';
import { createRepositories } from '@loop/patient-graph';
import { parseLabReport } from '@loop/biomarker-parser';ESM Extensions
Use .js extensions for local imports (ESM compatibility):
// Correct
import { helper } from './utils.js';
// Incorrect (will fail in ESM)
import { helper } from './utils';Package Boundaries
Each package has a clear responsibility. Don’t bypass boundaries:
@loop/core— Zero internal deps, foundational patterns@loop/shared— Schemas and types consumed by many packages@loop/health-data— Static data only, no database access@loop/patient-graph— Database operations only
Testing
Running Tests
# All tests
pnpm test
# Specific package
pnpm --filter @loop/health-engine test
# Watch mode
pnpm --filter @loop/health-engine test -- --watch
# With coverage
pnpm --filter @loop/health-engine test -- --coverageWriting Tests
Tests use Vitest:
import { describe, it, expect } from 'vitest';
import { assessQualification } from '@loop/health-engine';
describe('assessQualification', () => {
it('qualifies a healthy patient', () => {
const result = assessQualification(
{ age: 35, sex: 'male', conditions: [], medications: [] },
[{ code: 'testosterone-total', value: 650, unit: 'ng/dL' }],
'bpc-157'
);
expect(result.ok).toBe(true);
expect(result.value.status).toBe('qualified');
});
it('disqualifies for absolute contraindication', () => {
const result = assessQualification(
{ age: 30, sex: 'female', conditions: ['pregnancy'], medications: [] },
[],
'bpc-157'
);
expect(result.ok).toBe(true);
expect(result.value.status).toBe('disqualified');
});
});Debugging
Common Issues
“Module not found” errors:
Run pnpm build from the root to build all packages.
Type errors across packages:
Run pnpm typecheck to see all type errors. Fix them in dependency order (core → shared → other packages → apps).
Database schema changes: After modifying Drizzle schemas, run migrations or schema push:
pnpm --filter @loop/patient-graph-api db:pushClerk auth not working locally:
Verify CLERK_SECRET_KEY and NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY are set. Check that your Clerk instance has the correct redirect URLs.
Logging
Enable debug logging:
LOG_LEVEL=debug pnpm --filter @loop/patient-graph-api devAPI Debugging
Use the built-in requestId in error responses to trace requests through logs:
{
"error": { "code": "NOT_FOUND", "message": "Profile not found" },
"meta": { "requestId": "req_abc123" }
}Search logs for req_abc123 to find the full request trace.