Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ bun dev
```

This command starts:

- PostgreSQL database (via Docker)
- Vite dev server with HMR
- Drizzle Studio for database management
Expand Down Expand Up @@ -80,12 +81,14 @@ This command starts:
| `bun run import:staging` | Import data from staging |

**Workflow:**

- **Development:** Use `db:push` for fast iteration (no migration files)
- **Staging/Production:** Use `db:generate` + `db:migrate` (tracked, reviewable changes)

Re-run `db:regenerate-auth` when adding Better Auth plugins or upgrading.

**Schema files:**

- `server/database/schema/auth.ts` - Auto-generated (safe to overwrite)
- `server/database/schema/app.ts` - Your custom tables (never overwritten)

Expand Down Expand Up @@ -151,5 +154,4 @@ GitHub Actions runs on every push and PR to `master`:

## TODO

- [ ] DB sync from staging (implement `scripts/import-staging.sh`)
- [ ] Sentry frontend integration
2 changes: 2 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
infisical
railway
vtsls
tailwindcss-language-server
vscode-langservers-extracted
biome
];
buildInputs = [ ];
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"all": "bun run format && bun run lint && bun run typecheck && bun run build && bun run test:dev",
"lint": "biome check . --write && bun run check-locale",
"test": "vitest run",
"test:dev": "infisical run -- vitest watch",
"test:dev": "infisical run -- vitest run",
Comment thread
drobilc marked this conversation as resolved.
"lint:check": "biome check . && bun run check-locale",
"format": "biome format . --write",
"format:check": "biome format .",
Expand Down
75 changes: 75 additions & 0 deletions server/README.md
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`.
9 changes: 8 additions & 1 deletion server/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import { resolve } from "node:path";
import { instrumentDrizzleClient } from "@kubiks/otel-drizzle";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
import env from "@/env";
import * as schema from "./schema";

const migrationsFolder = resolve(process.cwd(), "server/database/migrations");

export const db = drizzle(env.DATABASE_URL);
export const db = drizzle(env.DATABASE_URL, { schema });
instrumentDrizzleClient(db);

// Cross-driver, transaction-compatible handle: the prod pool, a pglite test db,
// and a transaction all satisfy it. Services type their `db` param as this.
// (The base PgDatabase intentionally hides driver internals like `.$client`.)
export type Database = PgDatabase<PgQueryResultHKT, typeof schema>;

export async function runMigrations() {
await migrate(db, { migrationsFolder });
}
2 changes: 1 addition & 1 deletion server/features/demo/index.ts
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);
15 changes: 7 additions & 8 deletions server/features/demo/routes/demo-trace.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import { expect, test } from "vitest";
import { createRouter } from "@/server/lib/router";
import { authMiddleware } from "@/server/middleware/auth.middleware";
import { getTestApp } from "@/server/utils/testing/test-app";
import { TestUser1, testUsers } from "@/server/utils/testing/test-users";
import { demoTraceRoute } from "./demo-trace";

const testApp = createRouter();
testApp.use(authMiddleware);
testApp.route("/demo-trace", demoTraceRoute);
// The route declares its own `requireAuth` guard, so the test mounts it as-is —
// no need to re-wire auth here. getTestApp supplies the ambient test providers.
const testApp = createRouter().route("/trace", demoTraceRoute);

test("returns default greeting without name param", async () => {
const { app, dbClient } = await getTestApp(testApp, { testUsers });

const res = await app.request("/demo-trace?skipDb=true&delay=0", {
const res = await app.request("/trace?skipDb=true&delay=0", {
headers: { "X-Test-User-Id": TestUser1.id },
});

Expand All @@ -24,7 +23,7 @@ test("returns default greeting without name param", async () => {
test("returns personalized greeting with name param", async () => {
const { app, dbClient } = await getTestApp(testApp, { testUsers });

const res = await app.request("/demo-trace?name=Tim&skipDb=true&delay=0", {
const res = await app.request("/trace?name=Tim&skipDb=true&delay=0", {
headers: { "X-Test-User-Id": TestUser1.id },
});

Expand All @@ -36,7 +35,7 @@ test("returns personalized greeting with name param", async () => {
test("returns 401 without authentication", async () => {
const { app, dbClient } = await getTestApp(testApp, { testUsers });

const res = await app.request("/demo-trace?skipDb=true&delay=0");
const res = await app.request("/trace?skipDb=true&delay=0");

expect(res.status).toBe(401);
dbClient.close();
Expand All @@ -45,7 +44,7 @@ test("returns 401 without authentication", async () => {
test("returns 400 when delay exceeds maximum", async () => {
const { app, dbClient } = await getTestApp(testApp, { testUsers });

const res = await app.request("/demo-trace?delay=9999&skipDb=true", {
const res = await app.request("/trace?delay=9999&skipDb=true", {
headers: { "X-Test-User-Id": TestUser1.id },
});

Expand Down
62 changes: 8 additions & 54 deletions server/features/demo/routes/demo-trace.ts
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);
},
);
52 changes: 52 additions & 0 deletions server/features/demo/service.ts
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");
Comment thread
drobilc marked this conversation as resolved.

logger.info("Demo trace completed");

return { message: name ? `Hello, ${name}!` : "Hello!" };
}
9 changes: 9 additions & 0 deletions server/features/demo/validators.ts
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>;
8 changes: 4 additions & 4 deletions server/features/otel/index.ts
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);
Loading
Loading