chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
# APOPHIS API Redesign — Unified Interface Document
|
||||
|
||||
## Rationale
|
||||
|
||||
Five independent interface reviews (Substack/minimalist, Jared Hanson/DX, WebReflections/performance, XP theorist, FRP/DDD theorist) were conducted. All five agreed on the core value proposition (schemas as contracts) but identified a shared set of problems: overgrown surface area, leaky abstractions, silent failures, and an over-engineered formula language. This document unifies their feedback into a single coherent redesign.
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
1. **Split what is separate**: Runtime validation and test generation are different concerns. Do not force them into one plugin.
|
||||
2. **Do not export internals**: The public API should fit on a postcard.
|
||||
3. **Fail loud**: A silent empty result is worse than a thrown error.
|
||||
4. **One way to do things**: No duplicate syntaxes, no overlapping annotations.
|
||||
5. **Types are documentation**: Every public type should prevent misuse at compile time.
|
||||
|
||||
---
|
||||
|
||||
## The New Public API
|
||||
|
||||
### Package Entry Point
|
||||
|
||||
```typescript
|
||||
import apophis from 'apophis-fastify'
|
||||
```
|
||||
|
||||
The package exports one default: the Fastify plugin. No `export * from './types'`.
|
||||
|
||||
### Plugin Registration
|
||||
|
||||
```typescript
|
||||
await fastify.register(apophis, {
|
||||
runtime: 'warn', // 'off' | 'warn' | 'error' — default: 'off'
|
||||
cleanup: false, // auto-cleanup on SIGINT/SIGTERM — default: false
|
||||
})
|
||||
```
|
||||
|
||||
- **`runtime`**: How to enforce contracts at runtime. `'off'` disables hooks. `'warn'` logs violations without failing the request. `'error'` throws (500). Default is `'off'` because runtime validation is a development aid, not a production default.
|
||||
- **`cleanup`**: Whether to register process signal handlers. Default `false` because serverless and CLI tools should not have their signals hijacked.
|
||||
|
||||
### Test Execution
|
||||
|
||||
```typescript
|
||||
// Contract tests (fast, deterministic)
|
||||
const contract = await fastify.apophis.contract({
|
||||
depth: 'quick', // 'quick' | 'standard' | 'thorough' | { runs: 75 }
|
||||
scope: 'admin', // optional scope filter
|
||||
seed: 12345, // optional reproducibility seed
|
||||
})
|
||||
|
||||
// Stateful tests (slower, property-based with fast-check)
|
||||
const stateful = await fastify.apophis.stateful({
|
||||
depth: 'standard',
|
||||
scope: 'admin',
|
||||
seed: 12345,
|
||||
})
|
||||
|
||||
// Both (if you really want)
|
||||
const [contract, stateful] = await Promise.all([
|
||||
fastify.apophis.contract({ depth: 'quick' }),
|
||||
fastify.apophis.stateful({ depth: 'standard' }),
|
||||
])
|
||||
```
|
||||
|
||||
- **`contract()`**: Validates postconditions against generated requests. Does not mutate state. Safe to run against production.
|
||||
- **`stateful()`**: Generates command sequences that create, mutate, and delete resources. Requires cleanup. Not safe for production databases.
|
||||
- No `mode: 'all'` merging. No `mergeTestSuites`. The user composes explicitly.
|
||||
|
||||
### Per-Route Validation (New)
|
||||
|
||||
```typescript
|
||||
// Validate a single route in <100ms
|
||||
const result = await fastify.apophis.check('POST', '/users')
|
||||
// => { ok: boolean, violations: ContractViolation[] }
|
||||
```
|
||||
|
||||
### Spec Extraction
|
||||
|
||||
```typescript
|
||||
const spec = fastify.apophis.spec()
|
||||
// => OpenAPISpec & { 'x-apophis-contracts': ContractSummary[] }
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
|
||||
```typescript
|
||||
// Manual cleanup (always available)
|
||||
const results = await fastify.apophis.cleanup()
|
||||
// => Array<{ resource: TrackedResource; deleted: boolean; error?: string }>
|
||||
```
|
||||
|
||||
### Scope Configuration
|
||||
|
||||
```typescript
|
||||
// Scopes are passed at plugin registration, not auto-discovered from env
|
||||
await fastify.register(apophis, {
|
||||
scopes: {
|
||||
prod: {
|
||||
headers: { 'x-api-key': 'secret' },
|
||||
metadata: { tenantId: 'prod-tenant' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Access headers for a scope
|
||||
const headers = fastify.apophis.scope('prod')
|
||||
// => Record<string, string>
|
||||
```
|
||||
|
||||
No `ScopeRegistry` class exposed. No `deriveFromRequest`. No env var auto-discovery. Scopes are configuration, not global state.
|
||||
|
||||
---
|
||||
|
||||
## Schema Annotations
|
||||
|
||||
### Required (Core Value)
|
||||
|
||||
| Annotation | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `x-category` | `'constructor' \| 'mutator' \| 'observer' \| 'destructor' \| 'utility'` | Route classification |
|
||||
| `x-requires` | `RequiresClause[]` | Preconditions |
|
||||
| `x-ensures` | `EnsuresClause[]` | Postconditions |
|
||||
|
||||
### Removed
|
||||
|
||||
| Annotation | Reason |
|
||||
|-----------|--------|
|
||||
| `x-invariants` | Move to plugin-level option: `invariants: ['response_body(this).id != null']` |
|
||||
| `x-regex` | JSON Schema `pattern` already exists. No duplication. |
|
||||
| `x-validate-runtime` | Replaced by plugin-level `runtime` option |
|
||||
|
||||
### Scope Filtering
|
||||
|
||||
```typescript
|
||||
fastify.get('/admin', {
|
||||
schema: {
|
||||
'x-scope': 'admin', // Still valid: restricts route to admin scope tests
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['status:200'],
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## APOSTL Formula Language
|
||||
|
||||
APOSTL remains the full-featured contract language. All features are preserved for complex protocol contracts (OAuth 2.1, etc.):
|
||||
|
||||
```
|
||||
// Comparisons
|
||||
response_body(this).id != null
|
||||
response_body(this).email == request_body(this).email
|
||||
response_code(this) == 201
|
||||
request_headers(this).authorization != null
|
||||
response_body(this).items matches "^test"
|
||||
|
||||
// Boolean combinations
|
||||
status:200 && response_body(this).id != null
|
||||
status:200 || status:201
|
||||
|
||||
// Conditionals
|
||||
if response_code(this) == 200 then response_body(this).id != null else true
|
||||
|
||||
// Quantified expressions
|
||||
for item in response_body(this).items: item.status == "active"
|
||||
exists item in response_body(this).items: item.id != null
|
||||
|
||||
// Temporal references
|
||||
previous(response_body(this).id) != null
|
||||
|
||||
// Implication
|
||||
status:200 => response_body(this).id != null
|
||||
|
||||
// Literals
|
||||
true, false, null, 42, "string", T, F
|
||||
```
|
||||
|
||||
### New: `status:` Is Real APOSTL
|
||||
|
||||
```
|
||||
// Parser now understands this natively
|
||||
status:201
|
||||
```
|
||||
|
||||
Adds `type: 'status'` to `FormulaNode`. No more special-case string prefix check in contract validation.
|
||||
|
||||
---
|
||||
|
||||
## Types (Curated Public API)
|
||||
|
||||
```typescript
|
||||
// Only these types are exported
|
||||
|
||||
export interface ApophisOptions {
|
||||
readonly runtime?: 'off' | 'warn' | 'error'
|
||||
readonly cleanup?: boolean
|
||||
readonly scopes?: Record<string, ScopeConfig>
|
||||
readonly invariants?: string[]
|
||||
}
|
||||
|
||||
export interface ScopeConfig {
|
||||
readonly headers: Record<string, string>
|
||||
readonly metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface TestConfig {
|
||||
readonly depth?: 'quick' | 'standard' | 'thorough' | { runs: number }
|
||||
readonly scope?: string
|
||||
readonly seed?: number
|
||||
}
|
||||
|
||||
export interface TestSuite {
|
||||
readonly tests: TestResult[]
|
||||
readonly summary: TestSummary
|
||||
readonly routes: RouteDisposition[] // NEW: every route discovered and its status
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
readonly ok: boolean
|
||||
readonly name: string
|
||||
readonly id: number
|
||||
readonly directive?: string
|
||||
readonly diagnostics?: TestDiagnostics
|
||||
}
|
||||
|
||||
export interface TestSummary {
|
||||
readonly passed: number
|
||||
readonly failed: number
|
||||
readonly skipped: number
|
||||
readonly timeMs: number
|
||||
}
|
||||
|
||||
export interface RouteDisposition {
|
||||
readonly path: string
|
||||
readonly method: string
|
||||
readonly status: 'tested' | 'skipped' | 'no-contract' | 'scope-filtered'
|
||||
readonly reason?: string
|
||||
}
|
||||
|
||||
export interface ContractViolation {
|
||||
readonly type: 'contract-violation'
|
||||
readonly kind: 'precondition' | 'postcondition' | 'invariant' | 'regex'
|
||||
readonly route: { readonly method: string; readonly path: string }
|
||||
readonly formula: string
|
||||
readonly request: {
|
||||
readonly body: unknown
|
||||
readonly headers: Record<string, string>
|
||||
readonly query: Record<string, unknown>
|
||||
readonly params: Record<string, unknown>
|
||||
}
|
||||
readonly response: {
|
||||
readonly statusCode: number
|
||||
readonly headers: Record<string, string>
|
||||
readonly body: unknown
|
||||
}
|
||||
readonly context: {
|
||||
readonly expected: string
|
||||
readonly actual: string
|
||||
readonly diff?: string | null
|
||||
}
|
||||
readonly suggestion: string
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
readonly ok: boolean
|
||||
readonly violations: ContractViolation[]
|
||||
}
|
||||
|
||||
// Internal types are NOT exported:
|
||||
// FormulaNode, EvalContext, ModelState, ApiCommand, CacheEntry, etc.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Loud Failures (No Silent Empty Results)
|
||||
|
||||
```typescript
|
||||
// If no routes are discovered, THROW
|
||||
const result = await fastify.apophis.contract()
|
||||
// => throws: No routes discovered. Did you register APOPHIS before defining routes?
|
||||
|
||||
// If scope filter excludes all routes, THROW
|
||||
await fastify.apophis.contract({ scope: 'nonexistent' })
|
||||
// => throws: Scope 'nonexistent' not found. Available scopes: ['admin', 'user']
|
||||
|
||||
// If formula parse fails, THROW with route context
|
||||
// => ParseError: POST /users, x-ensures[1]: "response_body(this).id != nul"
|
||||
// Parse error at position 28: Expected identifier
|
||||
// response_body(this).id != nul
|
||||
// ^
|
||||
```
|
||||
|
||||
### Diagnostics in TestSuite
|
||||
|
||||
```typescript
|
||||
const result = await fastify.apophis.contract()
|
||||
|
||||
// Every route is accounted for
|
||||
for (const route of result.routes) {
|
||||
console.log(`${route.method} ${route.path}: ${route.status}`)
|
||||
// GET /health: tested
|
||||
// POST /users: tested
|
||||
// GET /admin: scope-filtered (scope: 'admin' not in test config)
|
||||
// DELETE /items/:id: no-contract (no x-ensures or x-requires)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration from v0.x to v1.0
|
||||
|
||||
### Plugin Registration
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
await fastify.register(apophis, { validateRuntime: true })
|
||||
|
||||
// After
|
||||
await fastify.register(apophis, { runtime: 'error' })
|
||||
```
|
||||
|
||||
### Test Execution
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
await fastify.apophis.test({ mode: 'all', depth: 'quick' })
|
||||
|
||||
// After
|
||||
const contract = await fastify.apophis.contract({ depth: 'quick' })
|
||||
const stateful = await fastify.apophis.stateful({ depth: 'quick' })
|
||||
```
|
||||
|
||||
### Scope Configuration
|
||||
|
||||
```typescript
|
||||
// Before (env vars)
|
||||
// APOPHIS_SCOPE_PROD='{"headers":{"x-api-key":"secret"}}'
|
||||
await fastify.register(apophis)
|
||||
fastify.apophis.scope.getHeaders('prod')
|
||||
|
||||
// After (explicit config)
|
||||
await fastify.register(apophis, {
|
||||
scopes: {
|
||||
prod: { headers: { 'x-api-key': 'secret' } }
|
||||
}
|
||||
})
|
||||
fastify.apophis.scope('prod')
|
||||
```
|
||||
|
||||
### Removed Annotations
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
schema: {
|
||||
'x-invariants': ['response_body(this).id != null'],
|
||||
'x-regex': { email: '^[^@]+@[^@]+$' },
|
||||
'x-validate-runtime': false,
|
||||
}
|
||||
|
||||
// After
|
||||
schema: {
|
||||
// x-invariants moved to plugin option
|
||||
// x-regex replaced by JSON Schema pattern
|
||||
// x-validate-runtime replaced by plugin runtime option
|
||||
}
|
||||
```
|
||||
|
||||
### Formula Language
|
||||
|
||||
```typescript
|
||||
// Before (still works)
|
||||
'if response_code(this) == 200 then response_body(this).id != null else T'
|
||||
'for item in response_body(this): item.status == "active"'
|
||||
'previous(response_body(this).id) != null'
|
||||
|
||||
// After (removed)
|
||||
// Use boolean operators instead
|
||||
'response_code(this) == 200 && response_body(this).id != null'
|
||||
// Use array element access (if supported in evaluator)
|
||||
'response_body(this).items.0.status == "active"'
|
||||
// Temporal contracts removed until bounded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
| Metric | Target | How Verified |
|
||||
|--------|--------|-------------|
|
||||
| New user: npm install → passing test | < 5 minutes | examples.test.ts |
|
||||
| Error messages include request/response context | 100% | success-metrics.test.ts |
|
||||
| Suggestions for violations | 100% | success-metrics.test.ts |
|
||||
| Silent empty results | 0% | All test calls throw on empty discovery |
|
||||
| Public API surface | < 10 exported types | types.ts audit |
|
||||
| Formula parse errors with position | 100% | formula.test.ts |
|
||||
| Per-route validation latency | < 100ms | benchmark.test.ts |
|
||||
|
||||
---
|
||||
|
||||
## Remaining Work
|
||||
|
||||
### Phase 1: API Surface (Week 1)
|
||||
- [ ] Split `test()` into `contract()` and `stateful()` methods
|
||||
- [ ] Remove `mode` and `mergeTestSuites`
|
||||
- [ ] Add `check(method, path)` per-route validation
|
||||
- [ ] Add `routes` disposition metadata to `TestSuite`
|
||||
- [ ] Make empty discovery throw with diagnostic message
|
||||
- [ ] Curate exports: remove `FormulaNode`, `EvalContext`, `ModelState`, `ApiCommand`, `CacheEntry`, `FastifyInjectInstance`, `ResourceHierarchy` from public API
|
||||
- [ ] Remove `export * from './types'` from `index.ts`
|
||||
|
||||
### Phase 2: Plugin Options (Week 1)
|
||||
- [ ] Rename `validateRuntime` → `runtime: 'off' | 'warn' | 'error'`
|
||||
- [ ] Change default from `true` to `'off'`
|
||||
- [ ] Add `cleanup: boolean` option (default `false`)
|
||||
- [ ] Move scope config from env discovery to plugin option `scopes`
|
||||
- [ ] Add `invariants: string[]` plugin option (replacing per-route `x-invariants`)
|
||||
- [ ] Remove `x-validate-runtime` schema annotation
|
||||
|
||||
### Phase 3: APOSTL Simplification (Week 2)
|
||||
- [ ] Add `type: 'status'` to `FormulaNode` AST (make `status:201` real)
|
||||
- [ ] Remove `if/then/else` from parser
|
||||
- [ ] Remove `for`/`exists` quantifiers from parser
|
||||
- [ ] Remove `previous()` from parser
|
||||
- [ ] Remove `=>` implication from parser
|
||||
- [ ] Remove `T`/`F` shorthand from parser
|
||||
- [ ] Update all tests to use simplified syntax
|
||||
- [ ] Update documentation
|
||||
|
||||
### Phase 4: Schema Annotations (Week 2)
|
||||
- [ ] Remove `x-invariants` support (migrated to plugin option)
|
||||
- [ ] Remove `x-regex` support (use JSON Schema `pattern`)
|
||||
- [ ] Add `destructor` to `OperationCategory` type (or remove from docs)
|
||||
- [ ] Document annotation precedence rules
|
||||
|
||||
### Phase 5: Error Handling (Week 2)
|
||||
- [ ] Parse errors include route path, method, annotation index
|
||||
- [ ] Scope mismatch throws with available scopes list
|
||||
- [ ] `check()` returns `CheckResult` with violations array
|
||||
- [ ] All test calls fail loudly on empty discovery
|
||||
|
||||
### Phase 6: Types (Week 3)
|
||||
- [ ] Type `spec()` return as `ApophisSpec extends OpenAPI.Document`
|
||||
- [ ] Make `cacheHits`/`cacheMisses` required (or move to sub-object)
|
||||
- [ ] Use `seed?: number` instead of `seed: number | undefined`
|
||||
- [ ] Brand validated types: `ValidatedFormula`, `HttpMethod`
|
||||
- [ ] Fix `ContractViolation.formulaType` to distinguish pre/post/invariant/regex
|
||||
- [ ] Add `ContractViolation.kind` field
|
||||
|
||||
### Phase 7: Performance (Week 3)
|
||||
- [ ] Eager-import test runners (remove lazy imports)
|
||||
- [ ] Static export for `spec()` extraction
|
||||
- [ ] Cache parsed formulas at route registration time
|
||||
- [ ] Remove `mergeTestSuites` reindexing overhead
|
||||
|
||||
### Phase 8: Documentation (Week 4)
|
||||
- [ ] Rewrite getting-started.md with new API
|
||||
- [ ] Document simplified APOSTL grammar
|
||||
- [ ] Update all examples
|
||||
- [ ] Migration guide from v0.x
|
||||
- [ ] API reference (typedoc)
|
||||
|
||||
---
|
||||
|
||||
## Principles Checklist
|
||||
|
||||
- [x] Runtime validation and test generation are separate concerns
|
||||
- [x] Public API fits on a postcard (< 10 exported types)
|
||||
- [x] Silent empty results are eliminated (throw instead)
|
||||
- [x] One way to do things (no duplicate syntaxes)
|
||||
- [x] Types prevent misuse at compile time
|
||||
- [x] Signal handlers are opt-in
|
||||
- [x] Scope configuration is explicit, not magic
|
||||
- [x] Formula language is simplified to core use cases
|
||||
- [x] Every test call accounts for every route
|
||||
- [x] Error messages include full context (route, formula, position)
|
||||
Reference in New Issue
Block a user