83 lines
3.3 KiB
Markdown
83 lines
3.3 KiB
Markdown
# quinn.api — Contributor Guide
|
|
|
|
## Where does X go?
|
|
|
|
| Trigger | Layer | Path |
|
|
|---------|-------|------|
|
|
| New DB table / domain noun | `entities/<noun>/` | types, schema, repo, barrel |
|
|
| CRUD endpoint for one entity | `surfaces/<surface>/<noun>.ts` | import from entity barrel |
|
|
| Logic touching 2+ entities | `features/<verb>/` | e.g. `tour-planning`, `client-intake` |
|
|
| Auth, CORS, rate-limit, error shape | `app/middleware/` | applied in `app/server.ts` |
|
|
| DB connection, migrations runner | `shared/db/` | no other layer owns this |
|
|
| HTTP error helpers, fetch utils | `shared/http/` | |
|
|
| Mail, iCal, TOTP primitives | `shared/mail/`, `shared/ical/`, `shared/totp/` | |
|
|
|
|
Decision tree:
|
|
|
|
```
|
|
New work incoming
|
|
├─ Single noun, single table? → entities/<noun>/
|
|
├─ Route exposing entity CRUD? → surfaces/<surface>/<noun>.ts
|
|
├─ Orchestrates multiple entities? → features/<verb>/
|
|
└─ Framework primitive? → shared/<concern>/
|
|
```
|
|
|
|
## How to add an entity
|
|
|
|
```bash
|
|
bun run scaffold:entity <name> # kebab-case, e.g. financial-record
|
|
```
|
|
|
|
This generates four files under `src/entities/<name>/`:
|
|
|
|
- `types.ts` — domain interfaces and const arrays
|
|
- `schema.ts` — `<name>Migrations: readonly Migration[]`
|
|
- `repo.ts` — `list/get/create/update/delete` functions (no ORM)
|
|
- `index.ts` — barrel re-export
|
|
|
|
After scaffolding:
|
|
1. Replace the placeholder types with your real domain model.
|
|
2. Add `<name>Migrations` to the `runMigrations(...)` call in `src/app/server.ts`.
|
|
3. Mount the entity in a surface (see next section).
|
|
|
|
## How to add a surface route
|
|
|
|
```ts
|
|
// src/surfaces/my/<entity>.ts
|
|
import { Hono } from 'hono';
|
|
import { listFoos, createFoo } from '@/entities/foo';
|
|
import { getDb } from '@/shared/db';
|
|
|
|
export const foosRouter = new Hono()
|
|
.get('/', (c) => c.json(listFoos(getDb())))
|
|
.post('/', async (c) => {
|
|
const body = draftSchema.parse(await c.req.json());
|
|
return c.json(createFoo(getDb(), body), 201);
|
|
});
|
|
```
|
|
|
|
Then mount it in `src/surfaces/<surface>/index.ts`:
|
|
|
|
```ts
|
|
export const mySurface = new Hono()
|
|
.route('/foos', foosRouter);
|
|
```
|
|
|
|
Surfaces are thin. Keep each file under 80 lines. Validation lives in the surface; business logic lives in `features/`; DB access lives in `entities/`.
|
|
|
|
## Dependency rules
|
|
|
|
`shared` imports nothing outside itself. `entities` imports `shared` and other entities (via barrel only — never reach into another entity's internals). `features` imports `entities` and `shared`, never other features or surfaces. `surfaces` imports `features`, `entities`, and `shared`, never another surface. `app` imports everything. These rules are enforced by `dependency-cruiser` in CI — a failing lint blocks merge.
|
|
|
|
## Running tests
|
|
|
|
```bash
|
|
bun test # all tests
|
|
bun test src/__tests__/server # specific file
|
|
```
|
|
|
|
Tests use an in-memory SQLite via `createTestApp()` from `src/__tests__/harness.ts`. No real files are touched. Each test file calls `createTestApp()` in `beforeEach` and `ctx.close()` in `afterEach` to reset state between tests.
|
|
|
|
## Proposing a dependency-cruiser exception
|
|
|
|
If a boundary rule feels wrong for a legitimate reason, open a PR editing `.dependency-cruiser.cjs` with an inline comment explaining why the boundary should bend. Three or more exceptions filed in a quarter is a signal to revisit the rules rather than accumulate exceptions.
|