-
Notifications
You must be signed in to change notification settings - Fork 0
backend v2 #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
backend v2 #7
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
461e995
feat: cleanup some not great conventions
timkalan 3cf2a79
feat: a bit nicer wiring and such
timkalan 8ddf371
feat: sentry quick wins
timkalan e70b908
refactor: better database type handling
timkalan 1951fb3
feat: error helper
timkalan 7554720
fix: otel/hono handling better
timkalan b01c04a
feat: docs
timkalan 009eb70
chore: formatting
timkalan 5937259
feat: cache type of api (and fix icons)
timkalan ff76b41
chore: fix typo
drobilc File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| # Backend conventions | ||
|
|
||
| 1. Middleware that "enriches" context is run globally, e.g. for our user info, | ||
| and currently db (this is slightly controversial, the more general approach is | ||
| to simply import the db in the handler, but for us it buys us testability via | ||
| pglite and enables any potential RLS future). These providers run via | ||
| `api.use(...)` in `server.ts`. | ||
| 1. Access _decisions_ (auth, subscription, ...) are the opposite: never global, | ||
| always per-route guards — see `requireAuth` (`middleware/auth.middleware.ts`) | ||
| applied directly on the route in `demo/routes/demo-trace.ts`. A global guard | ||
| would gate every route mounted after it; keeping it per-route is what avoids | ||
| that leak. | ||
| 1. We develop this backend "feature first", meaning we split by features and one | ||
| feature owns its route prefix (e.g. a `chat` feature, owns the `/chat` prefix). | ||
| 1. When accessing user owned resources, e.g. a chat, remember that we first need | ||
| to check if the resource exists and if the user has access to it, which should | ||
| both return a 404, to avoid leaking information (a 403 would reveal the | ||
| resource exists). We could potentially log the cases tho, but not right now. | ||
| 1. Observability is handled via OTEL and Axiom. The latter provides an MCP to build | ||
| dashboards, which usually results in a better result than the default Axiom one. | ||
| 1. Always have the unhappy path in mind. | ||
|
|
||
| ## Error handling | ||
|
|
||
| We return **expected** errors from the handler via `apiError`, e.g. | ||
| `return apiError(c, 404, "Not found")` — and yes, even an anticipated 5xx counts | ||
| (a failing upstream is still an expected outcome). **Unexpected** errors we let | ||
| throw, to be handled by hono's own `onError` in [the server](./server.ts). | ||
|
|
||
| I know this smells a bit like Go, but there is a good reason: a returned error | ||
| from a handler shows up as part of the exported RPC type for the frontend | ||
| (only handler returns, though — guard/middleware returns don't), and I just like | ||
| errors as values. | ||
|
|
||
| So in practice, you should almost never do try/catch in handlers to return | ||
| 500\. The only reason to use try/catch is if you get a downstream error (e.g. | ||
| from Postgres) and you want to properly log/type it. | ||
|
|
||
| ## When features get large | ||
|
|
||
| For small features, the guiding principle is to just put everything inside | ||
| their handlers. One lean file that shouldn't be more than 200-300 lines long | ||
| that just holds all the logic. | ||
|
|
||
| Once a feature grows, split out only the parts that earn it: | ||
|
|
||
| - `routes/` — the thin handlers (guard → validate → call service → respond). | ||
| - `validators.ts` — zod schemas. | ||
| - `service.ts` — business logic; takes `db` as an argument. | ||
| - `queries.ts` — db access, pulled out of the service once queries pile up or | ||
| get shared. | ||
| - `lib/` — feature-local helpers. If a helper is useful to *other* features, it | ||
| graduates to `server/lib` instead. | ||
|
|
||
| The flow is handler → service → queries, with `db` passed down (never grabbed | ||
| from context inside a service). Add each file only when the feature grows or you | ||
| start sharing — a small feature stays one handler. | ||
|
|
||
| ## API paths | ||
|
|
||
| Paths concatenate down the mount tree — each level adds one segment: | ||
|
|
||
| 1. **`server.ts` owns the prefixes** — `/api` plus each feature's prefix | ||
| (`/demo`). The one place to read the whole URL map. | ||
| 1. **A feature's `index.ts` owns its in-feature paths** (`/`, `/:id`), relative | ||
| to itself — it never repeats its own prefix. | ||
| 1. **Route files are relative to the route** — usually just `/`. | ||
|
|
||
| ```ts | ||
| api.route("/demo", demo); // server.ts -> /api/demo | ||
| createRouter().route("/trace", demoTraceRoute); // demo/index.ts -> /demo/trace | ||
| createRouter().get("/", requireAuth, handler); // demo-trace.ts -> /trace | ||
| ``` | ||
|
|
||
| So a feature is prefix-agnostic — moving it is a one-line change in `server.ts`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import { createRouter } from "@/server/lib/router"; | ||
| import { demoTraceRoute } from "./routes/demo-trace"; | ||
|
|
||
| export const demo = createRouter().route("/demo-trace", demoTraceRoute); | ||
| export const demo = createRouter().route("/trace", demoTraceRoute); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,62 +1,16 @@ | ||
| import { zValidator } from "@hono/zod-validator"; | ||
| import { sql } from "drizzle-orm"; | ||
| import { z } from "zod"; | ||
| import { logger } from "@/server/lib/logger"; | ||
| import { createRouter } from "@/server/lib/router"; | ||
| import { withSpan } from "@/server/lib/tracing"; | ||
|
|
||
| const querySchema = z.object({ | ||
| name: z.string().min(1).optional(), | ||
| delay: z.coerce.number().min(0).max(5000).optional().default(500), | ||
| skipDb: z.coerce.boolean().optional().default(false), | ||
| }); | ||
| import { requireAuth } from "@/server/middleware/auth.middleware"; | ||
| import { runDemoTrace } from "../service"; | ||
| import { demoTraceQuerySchema } from "../validators"; | ||
|
|
||
| // Thin route: guard → validate → call service → respond. No business logic here. | ||
| export const demoTraceRoute = createRouter().get( | ||
| "/", | ||
| zValidator("query", querySchema), | ||
| requireAuth, | ||
| zValidator("query", demoTraceQuerySchema), | ||
| async (c) => { | ||
| const { name, delay, skipDb } = c.req.valid("query"); | ||
|
|
||
| logger.info({ name, delay, skipDb }, "Demo trace started"); | ||
|
|
||
| if (!skipDb) { | ||
| // This DB query is auto-traced by @opentelemetry/instrumentation-pg | ||
| const db = c.get("db"); | ||
| await db.execute( | ||
| sql`SELECT 1 as "connection_test", NOW() as "current_time"`, | ||
| ); | ||
| logger.info({ query: "connection_test" }, "Database query completed"); | ||
| } | ||
|
|
||
| // Only use withSpan when you need custom business logic grouping | ||
| await withSpan( | ||
| "demo.external_api_call", | ||
| { "demo.type": "simulation", "api.endpoint": "https://example.com" }, | ||
| async (span) => { | ||
| span.addEvent("api.request_started", { | ||
| "http.method": "GET", | ||
| "http.url": "https://example.com/api", | ||
| }); | ||
|
|
||
| // Simulate external API latency | ||
| await new Promise((resolve) => setTimeout(resolve, delay)); | ||
|
|
||
| span.addEvent("api.response_received", { | ||
| "http.status_code": 200, | ||
| "response.size_bytes": 1024, | ||
| }); | ||
|
|
||
| logger.info({ latency: delay }, "External API call completed"); | ||
| }, | ||
| ); | ||
|
|
||
| await fetch("https://jsonplaceholder.typicode.com/todos/1"); | ||
|
|
||
| logger.info("Demo trace completed"); | ||
|
|
||
| const greeting = name ? `Hello, ${name}!` : "Hello!"; | ||
| return c.json({ | ||
| message: greeting, | ||
| }); | ||
| const result = await runDemoTrace(c.get("db"), c.req.valid("query")); | ||
| return c.json(result); | ||
| }, | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { sql } from "drizzle-orm"; | ||
| import type { Database } from "@/server/database"; | ||
| import { logger } from "@/server/lib/logger"; | ||
| import { withSpan } from "@/server/lib/tracing"; | ||
| import type { DemoTraceInput } from "./validators"; | ||
|
|
||
| /** | ||
| * Business logic for the demo trace. Takes the db explicitly (not from context) | ||
| * so it stays unit-testable without HTTP and swappable in tests. | ||
| */ | ||
| export async function runDemoTrace( | ||
| database: Database, | ||
| { name, delay, skipDb }: DemoTraceInput, | ||
| ) { | ||
| logger.info({ name, delay, skipDb }, "Demo trace started"); | ||
|
|
||
| if (!skipDb) { | ||
| // This DB query is auto-traced by @opentelemetry/instrumentation-pg | ||
| await database.execute( | ||
| sql`SELECT 1 as "connection_test", NOW() as "current_time"`, | ||
| ); | ||
| logger.info({ query: "connection_test" }, "Database query completed"); | ||
| } | ||
|
|
||
| // Only use withSpan when you need custom business logic grouping | ||
| await withSpan( | ||
| "demo.external_api_call", | ||
| { "demo.type": "simulation", "api.endpoint": "https://example.com" }, | ||
| async (span) => { | ||
| span.addEvent("api.request_started", { | ||
| "http.method": "GET", | ||
| "http.url": "https://example.com/api", | ||
| }); | ||
|
|
||
| // Simulate external API latency | ||
| await new Promise((resolve) => setTimeout(resolve, delay)); | ||
|
|
||
| span.addEvent("api.response_received", { | ||
| "http.status_code": 200, | ||
| "response.size_bytes": 1024, | ||
| }); | ||
|
|
||
| logger.info({ latency: delay }, "External API call completed"); | ||
| }, | ||
| ); | ||
|
|
||
| await fetch("https://jsonplaceholder.typicode.com/todos/1"); | ||
|
drobilc marked this conversation as resolved.
|
||
|
|
||
| logger.info("Demo trace completed"); | ||
|
|
||
| return { message: name ? `Hello, ${name}!` : "Hello!" }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { z } from "zod"; | ||
|
|
||
| export const demoTraceQuerySchema = z.object({ | ||
| name: z.string().min(1).optional(), | ||
| delay: z.coerce.number().min(0).max(5000).optional().default(500), | ||
| skipDb: z.coerce.boolean().optional().default(false), | ||
| }); | ||
|
|
||
| export type DemoTraceInput = z.infer<typeof demoTraceQuerySchema>; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| import { createRouter } from "@/server/lib/router"; | ||
| import { Hono } from "hono"; | ||
| import { postTracesRoute } from "./routes/post-traces"; | ||
|
|
||
| export const otel = createRouter() | ||
| // Add feature-specific middleware here if needed | ||
| .route("/v1/traces", postTracesRoute); | ||
| // Plain Hono (not createRouter): this proxy needs no AppEnv providers, and it's | ||
| // mounted outside them — typing it AppEnv would falsely promise db/user. | ||
| export const otel = new Hono().route("/v1/traces", postTracesRoute); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.