chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
# Structuring Your Fastify App for APOPHIS
|
||||
|
||||
APOPHIS requires that you register its plugin **before** defining routes, and it needs to access your route schemas at test time. If your application is a single file that creates the server, connects to databases, registers routes, and starts listening, you cannot test it with APOPHIS.
|
||||
|
||||
This guide shows how to restructure a monolithic Fastify application into a testable plugin architecture.
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
Here is what Arbiter's setup looked like — a single file doing everything:
|
||||
|
||||
```typescript
|
||||
// server.ts — THE WRONG WAY
|
||||
import Fastify from 'fastify'
|
||||
import database from './database'
|
||||
import routes from './routes'
|
||||
|
||||
const fastify = Fastify()
|
||||
|
||||
// Database setup
|
||||
await database.connect(process.env.DATABASE_URL)
|
||||
|
||||
// Register plugins
|
||||
await fastify.register(require('@fastify/swagger'))
|
||||
await fastify.register(require('@fastify/cors'))
|
||||
|
||||
// Register routes
|
||||
fastify.register(routes)
|
||||
|
||||
// Add decorators
|
||||
fastify.decorate('db', database)
|
||||
|
||||
// Start server
|
||||
await fastify.listen({ port: 3000 })
|
||||
```
|
||||
|
||||
**Why this breaks APOPHIS:**
|
||||
|
||||
1. **Routes are registered before APOPHIS** — APOPHIS must hook into the registration process, so it must be registered first.
|
||||
2. **No way to create a test instance** — The database connection and server start are unconditional. You cannot create a second Fastify instance for testing without starting another server.
|
||||
3. **No cleanup hook** — File system state (WAL logs, uploaded files) accumulates between runs.
|
||||
4. **Side effects at import time** — Importing the file has side effects. You cannot import routes without importing the database connection.
|
||||
|
||||
---
|
||||
|
||||
## The Solution: App Factory Pattern
|
||||
|
||||
Separate **application creation** from **server startup**. Export a function that creates a configured Fastify instance without starting it.
|
||||
|
||||
### Recommended Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
app.ts # App factory: creates Fastify instance, registers plugins
|
||||
server.ts # Entry point: creates app, connects DB, starts server
|
||||
plugins/
|
||||
database.ts # Database connection plugin
|
||||
auth.ts # Auth decorator plugin
|
||||
routes/
|
||||
users.ts # Route definitions with schema annotations
|
||||
health.ts # Health check route
|
||||
test/
|
||||
setup.ts # Test bootstrap: creates app, registers APOPHIS
|
||||
contracts.test.ts # Contract test entry point
|
||||
```
|
||||
|
||||
### 1. App Factory (`src/app.ts`)
|
||||
|
||||
This file exports a function that creates a Fastify instance and registers all plugins **except** APOPHIS and the database connection. It should have no side effects.
|
||||
|
||||
```typescript
|
||||
import Fastify from 'fastify'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
// Your application plugins
|
||||
import databasePlugin from './plugins/database'
|
||||
import authPlugin from './plugins/auth'
|
||||
import userRoutes from './routes/users'
|
||||
import healthRoutes from './routes/health'
|
||||
|
||||
export interface AppOptions {
|
||||
// Pass configuration explicitly instead of reading env vars
|
||||
databaseUrl?: string
|
||||
jwtSecret?: string
|
||||
enableLogging?: boolean
|
||||
}
|
||||
|
||||
export async function buildApp(opts: AppOptions = {}): Promise<FastifyInstance> {
|
||||
const fastify = Fastify({
|
||||
logger: opts.enableLogging ?? true,
|
||||
// Disable request logging in test mode to reduce noise
|
||||
disableRequestLogging: process.env.NODE_ENV === 'test',
|
||||
})
|
||||
|
||||
// Register infrastructure plugins
|
||||
await fastify.register(databasePlugin, { url: opts.databaseUrl })
|
||||
await fastify.register(authPlugin, { secret: opts.jwtSecret })
|
||||
|
||||
// Register route plugins
|
||||
await fastify.register(userRoutes, { prefix: '/api/users' })
|
||||
await fastify.register(healthRoutes, { prefix: '/health' })
|
||||
|
||||
return fastify
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Database Plugin (`src/plugins/database.ts`)
|
||||
|
||||
Encapsulate database setup in a Fastify plugin. This makes it composable and testable.
|
||||
|
||||
```typescript
|
||||
import fp from 'fastify-plugin'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { createConnection } from './db-client'
|
||||
|
||||
export interface DatabasePluginOptions {
|
||||
url?: string
|
||||
}
|
||||
|
||||
export default fp(async (fastify: FastifyInstance, opts: DatabasePluginOptions) => {
|
||||
const db = await createConnection(opts.url ?? process.env.DATABASE_URL)
|
||||
|
||||
// Decorate fastify with db access
|
||||
fastify.decorate('db', db)
|
||||
|
||||
// Clean up on close
|
||||
fastify.addHook('onClose', async () => {
|
||||
await db.disconnect()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Route Files with Contracts (`src/routes/users.ts`)
|
||||
|
||||
Define routes in separate files. Each route file is a Fastify plugin that receives the parent instance.
|
||||
|
||||
```typescript
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export default async function userRoutes(fastify: FastifyInstance) {
|
||||
fastify.post('/', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': [
|
||||
'status:201',
|
||||
'response_body(this).id != null',
|
||||
'response_body(this).email matches "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"',
|
||||
],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 },
|
||||
email: { type: 'string', format: 'email' },
|
||||
},
|
||||
required: ['name', 'email'],
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const user = await fastify.db.users.create(req.body)
|
||||
reply.status(201)
|
||||
return user
|
||||
})
|
||||
|
||||
fastify.get('/:id', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': [
|
||||
'if status:200 then response_body(this).id != null',
|
||||
],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const user = await fastify.db.users.findById(req.params.id)
|
||||
if (!user) {
|
||||
reply.status(404)
|
||||
return { error: 'Not found' }
|
||||
}
|
||||
return user
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Production Entry Point (`src/server.ts`)
|
||||
|
||||
The production entry point imports the app factory, adds APOPHIS, connects to services, and starts the server.
|
||||
|
||||
```typescript
|
||||
import { buildApp } from './app'
|
||||
import apophis from 'apophis-fastify'
|
||||
|
||||
async function start() {
|
||||
const fastify = await buildApp({
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
jwtSecret: process.env.JWT_SECRET,
|
||||
})
|
||||
|
||||
// Register APOPHIS before ready() but after all routes
|
||||
await fastify.register(apophis, {
|
||||
runtime: process.env.NODE_ENV === 'production' ? 'error' : 'warn',
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
|
||||
// Start server
|
||||
await fastify.listen({ port: Number(process.env.PORT) || 3000 })
|
||||
|
||||
console.log(`Server listening on ${fastify.server.address()}`)
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
```
|
||||
|
||||
### 5. Test Bootstrap (`src/test/setup.ts`)
|
||||
|
||||
The test file creates a fresh app instance, registers APOPHIS, and runs contract tests against it.
|
||||
|
||||
```typescript
|
||||
import { buildApp } from '../app'
|
||||
import apophis from 'apophis-fastify'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export async function createTestApp(): Promise<FastifyInstance> {
|
||||
// Use test database or in-memory store
|
||||
const fastify = await buildApp({
|
||||
databaseUrl: process.env.TEST_DATABASE_URL ?? ':memory:',
|
||||
jwtSecret: 'test-secret',
|
||||
enableLogging: false,
|
||||
})
|
||||
|
||||
// Register APOPHIS for testing
|
||||
await fastify.register(apophis, {
|
||||
timeout: 2000, // Faster timeouts in tests
|
||||
cleanup: true, // Auto-cleanup resources
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
return fastify
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Contract Test Entry Point (`src/test/contracts.test.ts`)
|
||||
|
||||
```typescript
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { createTestApp } from './setup'
|
||||
|
||||
test('contract tests', async () => {
|
||||
const fastify = await createTestApp()
|
||||
|
||||
try {
|
||||
const result = await fastify.apophis.contract({
|
||||
depth: 'standard',
|
||||
seed: 42, // Deterministic
|
||||
})
|
||||
|
||||
console.log(result.summary)
|
||||
|
||||
// Fail the test suite if any contract fails
|
||||
assert.strictEqual(
|
||||
result.summary.failed,
|
||||
0,
|
||||
`Contract failures: ${result.tests
|
||||
.filter((t) => !t.ok)
|
||||
.map((t) => t.name)
|
||||
.join(', ')}`
|
||||
)
|
||||
} finally {
|
||||
// Always clean up
|
||||
await fastify.apophis.cleanup()
|
||||
await fastify.close()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Principles
|
||||
|
||||
### 1. No Side Effects at Import Time
|
||||
|
||||
**Wrong:**
|
||||
```typescript
|
||||
// db.ts
|
||||
export const db = await createConnection(process.env.DATABASE_URL) // Side effect!
|
||||
```
|
||||
|
||||
**Right:**
|
||||
```typescript
|
||||
// db.ts
|
||||
export async function createDb(url: string) {
|
||||
return createConnection(url)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Separate App Creation from Server Start
|
||||
|
||||
**Wrong:**
|
||||
```typescript
|
||||
// server.ts
|
||||
const app = Fastify()
|
||||
// ... setup ...
|
||||
await app.listen({ port: 3000 }) // Cannot test without starting server
|
||||
export default app
|
||||
```
|
||||
|
||||
**Right:**
|
||||
```typescript
|
||||
// app.ts
|
||||
export async function buildApp() {
|
||||
const app = Fastify()
|
||||
// ... setup without listen() ...
|
||||
return app
|
||||
}
|
||||
|
||||
// server.ts
|
||||
const app = await buildApp()
|
||||
await app.listen({ port: 3000 })
|
||||
```
|
||||
|
||||
### 3. Use Fastify Plugins for Everything
|
||||
|
||||
Routes, database connections, auth, decorators — everything should be a Fastify plugin. This makes composition explicit and testable.
|
||||
|
||||
### 4. APOPHIS Registration Order
|
||||
|
||||
```typescript
|
||||
// 1. Create app (registers routes)
|
||||
const app = await buildApp()
|
||||
|
||||
// 2. Register APOPHIS (hooks into existing routes)
|
||||
await app.register(apophis, opts)
|
||||
|
||||
// 3. Ready (compiles schemas)
|
||||
await app.ready()
|
||||
|
||||
// 4. Test or serve
|
||||
await app.apophis.contract({...})
|
||||
// OR
|
||||
await app.listen({...})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Handling Arbiter-Specific Issues
|
||||
|
||||
### File System State (WAL Logs)
|
||||
|
||||
If your server writes to files or WAL logs:
|
||||
|
||||
```typescript
|
||||
// test/setup.ts
|
||||
import { mkdirSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
export function createTestWorkspace() {
|
||||
const dir = join(tmpdir(), `apophis-test-${Date.now()}`)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
|
||||
return {
|
||||
path: dir,
|
||||
cleanup() {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// In your test:
|
||||
const workspace = createTestWorkspace()
|
||||
const app = await buildApp({
|
||||
dataDir: workspace.path, // Server writes here instead of production path
|
||||
})
|
||||
```
|
||||
|
||||
### Database Seeding
|
||||
|
||||
```typescript
|
||||
// test/setup.ts
|
||||
export async function seedTestDatabase(db: Database) {
|
||||
await db.migrate.latest()
|
||||
await db.seed.run()
|
||||
}
|
||||
|
||||
// In your contract test:
|
||||
const app = await createTestApp()
|
||||
await seedTestDatabase(app.db)
|
||||
```
|
||||
|
||||
### Complex Dependency Injection
|
||||
|
||||
If routes depend on external services (ledger, graph store):
|
||||
|
||||
```typescript
|
||||
// Use test doubles via plugin options
|
||||
export async function buildApp(opts: AppOptions) {
|
||||
const app = Fastify()
|
||||
|
||||
// Production: real ledger
|
||||
// Test: mock ledger
|
||||
await app.register(ledgerPlugin, {
|
||||
client: opts.ledgerClient ?? new RealLedgerClient(),
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
If you have a monolithic `server.ts` like Arbiter:
|
||||
|
||||
- [x] Extract route definitions into `src/routes/*.ts` files
|
||||
- [x] Extract database/auth setup into `src/plugins/*.ts` files
|
||||
- [x] Create `src/app.ts` with a `buildApp()` factory function
|
||||
- [x] Move `fastify.listen()` from `app.ts` to `src/server.ts`
|
||||
- [x] Create `src/test/setup.ts` that calls `buildApp()` + `apophis.register()`
|
||||
- [x] Ensure no side effects at import time in any `src/` file
|
||||
- [x] Run `npx tsc --noEmit` to verify no circular dependencies
|
||||
- [x] Run contract tests: `npm run test:contracts`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Monolithic | Plugin Architecture |
|
||||
|-----------|-------------------|
|
||||
| Single file with everything | Factory function + plugin files |
|
||||
| Side effects at import | Pure functions, explicit initialization |
|
||||
| Cannot create test instance | Create unlimited instances |
|
||||
| APOPHIS must be first (impossible) | APOPHIS registered after routes, before ready() |
|
||||
| Manual cleanup | Hooks for automatic cleanup |
|
||||
| Database URL hardcoded | Injected via options |
|
||||
|
||||
The plugin architecture takes 30 minutes to set up and saves hours of debugging when APOPHIS cannot access your routes.
|
||||
Reference in New Issue
Block a user