chore: crush git history - reborn from consolidation on 2026-03-10

This commit is contained in:
John Dvorak
2026-03-10 00:00:00 -07:00
commit d278c4b105
313 changed files with 87549 additions and 0 deletions
+476
View File
@@ -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)