diff --git a/AGENTS.md b/AGENTS.md index 77485b3..285b359 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,14 +2,21 @@ Guidelines for AI coding agents working in this repository. +Backend conventions (middleware philosophy, error handling, feature layout, +migration workflow) live in @server/README.md — read it before writing backend +code (the @ auto-imports it for Claude Code; other agents: open the file). + ## Tech Stack - **Runtime:** Bun - **Frontend:** React 19 + Vite + TailwindCSS v4 +- **Data fetching:** TanStack Query over type-safe Hono RPC - **Backend:** Hono (running on Bun) - **Database:** PostgreSQL + Drizzle ORM - **Auth:** Better Auth - **Validation:** Zod +- **Testing:** Vitest + PGlite (ephemeral test databases) +- **Secrets:** Infisical - **Linting/Formatting:** Biome - **i18n:** i18next @@ -18,48 +25,67 @@ Guidelines for AI coding agents working in this repository. ```bash # Development bun dev # Start full dev environment (DB, Vite, Drizzle Studio) -bun run build # Production build +bun run build # Production build (SSG prerender + Vite build) bun start # Run production server +# Testing +bun run test:dev # Run vitest with env injected — use this locally +bun run test # Run vitest bare (CI provides env) + # Code Quality bun run lint # Fix lint issues (Biome + locale check) bun run lint:check # Check only (CI) bun run format # Fix formatting bun run format:check # Check only (CI) bun run typecheck # Type-check all projects -bun run all # Run format + lint + typecheck + build +bun run all # format + lint + typecheck + build + test # Database bun run db:start # Start PostgreSQL container -bun run db:push # Sync schema to DB (dev only) -bun run db:generate # Generate migrations -bun run db:migrate # Apply migrations -bun run db:regenerate-auth # Regenerate Better Auth schema +bun run db:push # Sync schema to DB (dev iteration only) +bun run db:generate # Generate migration SQL (once, when the branch is ready) +bun run db:migrate # Apply migrations manually (deploys apply them on boot) +bun run auth:generate # Regenerate Better Auth schema (after plugin changes) ``` +### Secrets (Infisical) + +Env-dependent scripts (`dev`, `db:*`, `test:dev`, `auth:generate`) are wrapped +in `infisical run --` — secrets come from Infisical, not a local `.env`. +Running the underlying tools bare (`vitest`, `drizzle-kit push`) fails `env.ts` +validation; always go through the package scripts. + ## Project Structure ``` web/ # Frontend (React) components/ui/ # Reusable UI components (shadcn pattern) - lib/api.ts # Type-safe Hono RPC client + lib/api.ts # Hono RPC client wrapped for TanStack Query + lib/typed-client.ts # queryOptions/mutationOptions wrapper (typed ApiError) lib/auth-client.ts # Better Auth client i18n/locales/ # Translation files -server/ # Backend (Hono) - server.tsx # Entry point & route assembly - auth.ts # Better Auth config - logger.ts # Pino logger with trace context - middleware/ # Global middleware +server/ # Backend (Hono) — conventions in server/README.md + server.ts # Entry point & route assembly + lib/ # Shared server infra + auth.ts # Better Auth config + logger.ts # Pino logger with trace context + http.ts # apiError helper + router.ts # createRouter (Hono with typed context) + tracing.ts # withSpan + span helpers + middleware/ # Ambient providers (session, db) + per-route guards (requireAuth) features/ # Feature modules (one folder per feature) {feature}/ - index.ts # Re-exports feature routes - routes/ # One file per route handler + index.ts # Assembles the feature's routes + routes/ # One file per route handler (+ colocated *.test.ts) + utils/testing/ # Test helpers (getTestApp, PGlite test DB, test users) database/ schema/auth.ts # Better Auth tables (auto-generated) schema/app.ts # Custom tables (add your tables here) + migrations/ # Generated SQL, applied on server boot -env.ts # Environment schema (Zod) +env.ts # Environment schema (@t3-oss/env-core + Zod) +scripts/ # Dev/CI scripts (check-locale, db-import) ``` ## Code Style @@ -85,77 +111,141 @@ env.ts # Environment schema (Zod) ### Hono Routes - One route handler per file in `server/features/{feature}/routes/` -- Use Zod validation with `zValidator`: +- Build routes with `createRouter()` (typed context), never bare `new Hono()` +- Guards (`requireAuth`) are declared per-route, never globally; ambient + middleware already provides `c.get("db")` and `c.get("user")` ```typescript import { zValidator } from "@hono/zod-validator"; -import { Hono } from "hono"; import { z } from "zod"; +import { createRouter } from "@/server/lib/router"; +import { requireAuth } from "@/server/middleware/auth.middleware"; -const schema = z.object({ name: z.string().min(1) }); +const schema = z.object({ name: z.string().trim().min(1) }); -export const myRoute = new Hono().get( +export const createItemRoute = createRouter().post( "/", - zValidator("query", schema), + requireAuth, + zValidator("json", schema), async (c) => { - const { name } = c.req.valid("query"); - return c.json({ message: `Hello, ${name}!` }); - } + const db = c.get("db"); + const user = c.get("user"); + const { name } = c.req.valid("json"); + + const [created] = await db + .insert(item) + .values({ name, ownerId: user.id }) + .returning(); + return c.json(created, 201); + }, ); ``` +### Error Handling +- Validate input with Zod via `zValidator` +- **Expected** errors are returned: `return apiError(c, 409, "Limit reached")` + (from `@/server/lib/http`) — these become typed error responses in the RPC + client. **Unexpected** errors just throw; Hono's `onError` logs and maps them + to 500. Details: `server/README.md` § Error handling +- Wrap frontend pages in `` + ### Database (Drizzle) - Define tables in `server/database/schema/app.ts` - Use `drizzle-orm/pg-core` for table definitions +- Import tables from `@/server/database/schema` (the index re-exports all) - Reference auth tables from `./auth.ts` for foreign keys ### Environment Variables -- Define in `env.ts` with Zod schemas +- Define in `env.ts` — add new variables to **both** the `server` schema and + `runtimeEnvStrict` - Access via `import env from "@/env"` -- Never hardcode secrets +- Values are injected by Infisical; never hardcode secrets ### i18n - Add translations to `web/i18n/locales/{lang}/common.ts` - Use `useTranslation("common")` hook - Run `bun run check-locale` to verify all keys exist in all locales -### Error Handling -- Use Zod for input validation -- Wrap pages in `` -- Server errors logged via `logger` from `@/server/logger` - ### Logging (Server) ```typescript -import { logger } from "@/server/logger"; +import { logger } from "@/server/lib/logger"; logger.info({ userId, action }, "User performed action"); ``` Logger auto-injects traceId, spanId, userId when available. ### Tracing (OpenTelemetry) - HTTP requests, DB queries, and fetch calls are auto-traced -- For custom spans: `import { withSpan } from "@/server/tracing"` +- For custom spans: `import { withSpan } from "@/server/lib/tracing"` + +## Testing + +- Tests are colocated with routes: `routes/{route-name}.test.ts` +- `getTestApp` mounts your router on a fresh PGlite database (migrations + applied) with test auth middleware — authenticate by sending an + `X-Test-User-Id` header; omit it to exercise 401 paths +- Run with `bun run test:dev` + +```typescript +import { expect, test } from "vitest"; +import { createRouter } from "@/server/lib/router"; +import { getTestApp } from "@/server/utils/testing/test-app"; +import { TestUser1, testUsers } from "@/server/utils/testing/test-users"; +import { createItemRoute } from "./create-item"; + +test("creates an item", async () => { + const { app, dbClient } = await getTestApp( + createRouter().route("/", createItemRoute), + { testUsers }, + ); + + const res = await app.request("/", { + method: "POST", + headers: { "X-Test-User-Id": TestUser1.id, "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Hello" }), + }); + + expect(res.status).toBe(201); + dbClient.close(); +}); +``` ## Adding New Features -**Backend Route:** Create `server/features/{feature}/routes/{route-name}.ts`, re-export in `index.ts`, mount in `server/server.tsx` +**Backend Route:** Create `server/features/{feature}/routes/{route-name}.ts`, +assemble in the feature's `index.ts`, mount the prefix in `server/server.ts`. +Path layering rules: `server/README.md` § API paths **Frontend Page:** Add route to `web/router.tsx`, create component in `web/components/` -**Database Table:** Add to `server/database/schema/app.ts`, run `bun run db:push` (dev) or `db:generate && db:migrate` (prod) +**Database Table:** Add to `server/database/schema/app.ts`. While iterating run +`bun run db:push`; when the branch is ready run `bun run db:generate` once and +commit the migration — deploys apply it on boot. Details: `server/README.md` +§ Database migrations ## Better Auth -- Config: `server/auth.ts` +- Config: `server/lib/auth.ts` - Client: `web/lib/auth-client.ts` -- After adding plugins: `bun run db:regenerate-auth` -- Session in routes: `await auth.api.getSession({ headers: c.req.raw.headers })` +- After adding plugins: `bun run auth:generate` +- Current user in routes: apply `requireAuth` and read `c.get("user")` — the + ambient `sessionMiddleware` resolves the session once per request; never call + `auth.api.getSession` inside a handler ## API Client (Frontend) -Use type-safe Hono RPC: +The Hono RPC client is wrapped (`web/lib/typed-client.ts`) so every endpoint +exposes `queryOptions` / `mutationOptions` with a typed `ApiError` channel: + ```typescript +import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "~/lib/api"; -import { useMutation } from "@tanstack/react-query"; -const mutation = useMutation(api["my-route"].$get.mutationOptions({})); -mutation.mutate({ query: { name: "World" } }); +// Queries: request input goes in `input`, TanStack options alongside +const itemsQuery = useQuery(api.items.$get.queryOptions({ enabled: !!session })); + +// Mutations: `mutate` takes the Hono request shape +const createItem = useMutation(api.items.$post.mutationOptions({})); +createItem.mutate({ json: { name: "Hello" } }); + +// Errors are typed: `status` is a literal union that narrows `body` +const message = createItem.error?.body.error; ``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/bun.lock b/bun.lock index 7cd43e7..81d79b5 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "setup", "dependencies": { - "@better-auth/core": "1.5.5", - "@better-auth/infra": "^0.1.12", + "@better-auth/core": "^1.6.14", + "@better-auth/infra": "^0.2.13", "@hono/otel": "^1.1.0", "@hono/zod-validator": "^0.7.6", "@kubiks/otel-better-auth": "^2.0.2", @@ -31,7 +31,7 @@ "@t3-oss/env-core": "^0.13.10", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.19", - "better-auth": "^1.5.5", + "better-auth": "^1.6.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-kit": "^0.31.8", @@ -120,25 +120,25 @@ "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], - "@better-auth/core": ["@better-auth/core@1.5.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA=="], + "@better-auth/core": ["@better-auth/core@1.6.14", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-12cA7tnR4Wyb3nLpPmeq/Id7QNB+4OhjbzuX7sIhqglgXGjyT5iiNpe2lx/8FF532sHC450Yx1850salCYbkzw=="], - "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-HAi9xAP40oDt48QZeYBFTcmg3vt1Jik90GwoRIfangd7VGbxesIIDBJSnvwMbZ52GBIc6+V4FRw9lasNiNrPfw=="], + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.14", "", { "peerDependencies": { "@better-auth/core": "^1.6.14", "@better-auth/utils": "0.4.1", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-lYs1jDudriKYMXNcLFLAvEvOEKbeKBFdDciG4H8qZhV+3+yghGC3f/H5qtgTDc8mGBPV+2tEvVgYqReurOSmNw=="], - "@better-auth/infra": ["@better-auth/infra@0.1.12", "", { "dependencies": { "@better-fetch/fetch": "^1.1.21", "better-call": "^1.3.3", "jose": "^6.1.0", "libphonenumber-js": "^1.12.36" }, "peerDependencies": { "@better-auth/core": ">=1.4.0", "@better-auth/sso": ">=1.4.0", "better-auth": ">=1.4.0", "zod": ">=4.1.12" } }, "sha512-NCOX5YIyCfJXJ8HDIyZdRpMuJtjQD8+tNYd+goXkgL+/6GYwEYOm4dRRcSmqlODsjL7vC4ZHfo9bfqpafq/9Nw=="], + "@better-auth/infra": ["@better-auth/infra@0.2.13", "", { "dependencies": { "@better-fetch/fetch": "^1.1.21", "better-call": "^1.3.2", "jose": "^6.1.0", "libphonenumber-js": "^1.13.3" }, "peerDependencies": { "@better-auth/core": ">=1.4.0", "@better-auth/sso": ">=1.4.0", "@react-native-async-storage/async-storage": ">=1.21.0", "better-auth": ">=1.4.0", "expo-constants": ">=16.0.0", "expo-crypto": ">=13.0.0", "expo-device": ">=6.0.0", "react-native": ">=0.74.0", "zod": ">=4.1.12" }, "optionalPeers": ["@react-native-async-storage/async-storage", "expo-constants", "expo-crypto", "expo-device", "react-native"] }, "sha512-Vx9SGexYRl5Y1x0qMQrJ6nOpeU3vQtqmRl+GIe4KQbv7ookQx8qdNH46Dykidvi71jlFLCt1fd6LwpOrKphaPQ=="], - "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "kysely": "^0.27.0 || ^0.28.0" } }, "sha512-LmHffIVnqbfsxcxckMOoE8MwibWrbVFch+kwPKJ5OFDFv6lin75ufN7ZZ7twH0IMPLT/FcgzaRjP8jRrXRef9g=="], + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.14", "", { "peerDependencies": { "@better-auth/core": "^1.6.14", "@better-auth/utils": "0.4.1", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-A2+381gYADuZpgd98XQ39bnxLzbT03wnnDmSQIXp7XcE3hF093mGMk6rxlAhENVHH7JL2B0Tv2la2o6n+6ppyQ=="], - "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0" } }, "sha512-4X0j1/2L+nsgmObjmy9xEGUFWUv38Qjthp558fwS3DAp6ueWWyCaxaD6VJZ7m5qPNMrsBStO5WGP8CmJTEWm7g=="], + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.14", "", { "peerDependencies": { "@better-auth/core": "^1.6.14", "@better-auth/utils": "0.4.1" } }, "sha512-frtBTozi8qsBlypxp33dkiIZT2IOMvix3oh2qTTcBkK11ISsRSTUUadl7DbwXri2AEoooShsH6PSAput920J3Q=="], - "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "mongodb": "^6.0.0 || ^7.0.0" } }, "sha512-P1J9ljL5X5k740I8Rx1esPWNgWYPdJR5hf2CY7BwDSrQFPUHuzeCg0YhtEEP55niNateTXhBqGAcy0fVOeamZg=="], + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.14", "", { "peerDependencies": { "@better-auth/core": "^1.6.14", "@better-auth/utils": "0.4.1", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-meaZx712k9c0Cl6urwYZRNa3mAy3/leaYiSNt+hVaCOEPlgTDxzmYMNACvTTYXgh4eCpDVf5G7ZMEYBtejKQdw=="], - "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-CliDd78CXHzzwQIXhCdwGr5Ml53i6JdCHWV7PYwTIJz9EAm6qb2RVBdpP3nqEfNjINGM22A6gfleCgCdZkTIZg=="], + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.14", "", { "peerDependencies": { "@better-auth/core": "^1.6.14", "@better-auth/utils": "0.4.1", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-9b9wSqhCthMmOYo0QdX+N/cOv+fNck/JE5CZQuuWwEJl5QeoYhCZesXjts5VfLAPMIf6vKw3QNBrn0SVMXXi2Q=="], "@better-auth/sso": ["@better-auth/sso@1.5.5", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "fast-xml-parser": "^5.4.1", "jose": "^6.1.3", "samlify": "^2.10.2", "tldts": "^6.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "1.5.5", "better-auth": "1.5.5", "better-call": "1.3.2" } }, "sha512-G3tvv5oKtEfpmBrt7Db/hSl5A3xttUkB4EhEjb202UhHz/XBiT0Orv5CkRa0kmjRyyAwOzn/lKZzYsd3VrjViA=="], - "@better-auth/telemetry": ["@better-auth/telemetry@1.5.5", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.5.5" } }, "sha512-1+lklxArn4IMHuU503RcPdXrSG2tlXt4jnGG3omolmspQ7tktg/Y9XO/yAkYDurtvMn1xJ8X1Ov01Ji/r5s9BQ=="], + "@better-auth/telemetry": ["@better-auth/telemetry@1.6.14", "", { "peerDependencies": { "@better-auth/core": "^1.6.14", "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21" } }, "sha512-ALi3cEx5eyrFY+TeAdhc1uq8FqJyGvzgvIo7GQZOqGqLZxHY9nte44WN++jBFGJJbsW3e4cgLj8dQK291s6wWQ=="], - "@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="], + "@better-auth/utils": ["@better-auth/utils@0.4.1", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-SZBPRPF3z0nBvE5ygOkxae35wnnXPRShmqFo78S+qslLeFoPu/pMgnXAuNKFMMybac3tiLaVg1e3MQW5MC+1iA=="], "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], @@ -258,8 +258,6 @@ "@kubiks/otel-drizzle": ["@kubiks/otel-drizzle@2.1.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "drizzle-orm": ">=0.28.0" } }, "sha512-9UHb0od3jwa6zTWMyEYPIZcUq5PDaziCmQLMLakSK2zeqy12SFZ3SAGWXJTgEr8valn/Wa+DKVs+Z3aqKQUpvg=="], - "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="], - "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], @@ -596,10 +594,6 @@ "@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="], - "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], - - "@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], @@ -642,9 +636,9 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], - "better-auth": ["better-auth@1.5.5", "", { "dependencies": { "@better-auth/core": "1.5.5", "@better-auth/drizzle-adapter": "1.5.5", "@better-auth/kysely-adapter": "1.5.5", "@better-auth/memory-adapter": "1.5.5", "@better-auth/mongo-adapter": "1.5.5", "@better-auth/prisma-adapter": "1.5.5", "@better-auth/telemetry": "1.5.5", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg=="], + "better-auth": ["better-auth@1.6.14", "", { "dependencies": { "@better-auth/core": "1.6.14", "@better-auth/drizzle-adapter": "1.6.14", "@better-auth/kysely-adapter": "1.6.14", "@better-auth/memory-adapter": "1.6.14", "@better-auth/mongo-adapter": "1.6.14", "@better-auth/prisma-adapter": "1.6.14", "@better-auth/telemetry": "1.6.14", "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-c0/DvTQGDpgfj1knekCpQrg6PSWGDtfAtP7Ou6FkAhoE3RNnnIxLB5qKj6tRg53a1xsq93G6T68cNxrUZ7ZVmw=="], - "better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="], + "better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], @@ -652,8 +646,6 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - "bson": ["bson@7.2.0", "", {}, "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ=="], - "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], @@ -834,9 +826,9 @@ "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], - "kysely": ["kysely@0.28.12", "", {}, "sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw=="], + "kysely": ["kysely@0.29.2", "", {}, "sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg=="], - "libphonenumber-js": ["libphonenumber-js@1.12.40", "", {}, "sha512-HKGs7GowShNls3Zh+7DTr6wYpPk5jC78l508yQQY3e8ZgJChM3A9JZghmMJZuK+5bogSfuTafpjksGSR3aMIEg=="], + "libphonenumber-js": ["libphonenumber-js@1.13.6", "", {}, "sha512-NdB6O6QvlGMCoG003m0YIKG2+Xw7DjmCZhmc1RH+K6HncADUbRf8TZeLegxBBN1VFyPHcNpPTKpIhYLXzJVy1Q=="], "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], @@ -876,8 +868,6 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="], - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -886,10 +876,6 @@ "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], - "mongodb": ["mongodb@7.1.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.1.1", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg=="], - - "mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -1034,8 +1020,6 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], - "sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="], - "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -1152,9 +1136,13 @@ "@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="], + "@better-auth/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], + "@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "@better-auth/infra/better-call": ["better-call@1.3.4", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-ZhY7Wy1usw/YpanMBsvY+cCsdTa6k96iuetRrndvgpFSjl3Bfdqa6DxC6XJf4lzRYqxxtpJiCTjbBkHdSI7hOQ=="], + "@better-auth/sso/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="], + + "@better-auth/sso/better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="], "@better-auth/sso/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], diff --git a/package.json b/package.json index 744c9a5..e243ecf 100644 --- a/package.json +++ b/package.json @@ -51,8 +51,8 @@ "typescript": "^5" }, "dependencies": { - "@better-auth/core": "1.5.5", - "@better-auth/infra": "^0.1.12", + "@better-auth/core": "^1.6.14", + "@better-auth/infra": "^0.2.13", "@hono/otel": "^1.1.0", "@hono/zod-validator": "^0.7.6", "@kubiks/otel-better-auth": "^2.0.2", @@ -77,7 +77,7 @@ "@t3-oss/env-core": "^0.13.10", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.19", - "better-auth": "^1.5.5", + "better-auth": "^1.6.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-kit": "^0.31.8", diff --git a/server/README.md b/server/README.md index 8ae704f..b262eac 100644 --- a/server/README.md +++ b/server/README.md @@ -56,6 +56,23 @@ The flow is handler → service → queries, with `db` passed down (never grabbe from context inside a service). Add each file only when the feature grows or you start sharing — a small feature stays one handler. +**Extract on a pressure, not for symmetry.** The default is a fat handler that +owns its whole route; that locality is the point (one file is the complete +truth — easiest to read, change, and reason about). Reach for a layer only when +a concrete pressure shows up: + +- **Sharing** — a query is needed by a second route → pull it into `queries.ts`. +- **Size** — a handler grows past what reads in one screen → pull the logic into + a `service` function. +- **Testability** — you want to exercise a rule without faking HTTP → a `service` + function takes `db` + args (never `c`), so it's unit-testable. + +The burden of proof is on the layer, not the handler. **Never add a pass-through +layer** — a `service` that only forwards to one query is a smell; inline it. +Reflexively giving every feature a `service.ts` + `queries.ts` is how you get +ravioli: a three-line route smeared across four files, with indirection you pay +on every read. When in doubt, leave it in the handler. + ## API paths Paths concatenate down the mount tree — each level adds one segment: @@ -73,3 +90,22 @@ createRouter().get("/", requireAuth, handler); // demo-trace.ts -> /trace ``` So a feature is prefix-agnostic — moving it is a one-line change in `server.ts`. + +## Database migrations + +Schema lives in `database/schema`. The path from a schema edit to staging is +split on purpose: + +1. **Local dev → `db:push`.** Edit the schema, run `bun run db:push`, Drizzle + syncs your local DB to match. No migration files — push is for fast iteration + while the shape is still moving. +1. **Branch ready → `db:generate`.** Once the schema has settled, run + `bun run db:generate` to emit the SQL migration into `database/migrations` + and commit it. That committed SQL is the reviewable, tracked artifact — + generate *once*, at the end, not per tweak. +1. **Staging/prod → automatic.** Migrations apply on server boot via + `runMigrations()` (see `server.ts`), so deploying the branch applies the + committed migration. Nothing manual. + +The rule of thumb: **never `generate` mid-dev.** Push while iterating, generate +once when the branch is ready, let the deploy apply it. diff --git a/server/database/migrations/0000_chunky_fabian_cortez.sql b/server/database/migrations/0000_empty_alice.sql similarity index 65% rename from server/database/migrations/0000_chunky_fabian_cortez.sql rename to server/database/migrations/0000_empty_alice.sql index 682e8eb..6931ce0 100644 --- a/server/database/migrations/0000_chunky_fabian_cortez.sql +++ b/server/database/migrations/0000_empty_alice.sql @@ -1,8 +1,16 @@ +CREATE TABLE "project" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "owner_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint CREATE TABLE "account" ( - "id" text PRIMARY KEY NOT NULL, + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, "account_id" text NOT NULL, "provider_id" text NOT NULL, - "user_id" text NOT NULL, + "user_id" uuid NOT NULL, "access_token" text, "refresh_token" text, "id_token" text, @@ -15,30 +23,36 @@ CREATE TABLE "account" ( ); --> statement-breakpoint CREATE TABLE "session" ( - "id" text PRIMARY KEY NOT NULL, + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, "expires_at" timestamp NOT NULL, "token" text NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL, "updated_at" timestamp NOT NULL, "ip_address" text, "user_agent" text, - "user_id" text NOT NULL, + "user_id" uuid NOT NULL, + "impersonated_by" text, CONSTRAINT "session_token_unique" UNIQUE("token") ); --> statement-breakpoint CREATE TABLE "user" ( - "id" text PRIMARY KEY NOT NULL, + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, "name" text NOT NULL, "email" text NOT NULL, "email_verified" boolean DEFAULT false NOT NULL, "image" text, "created_at" timestamp DEFAULT now() NOT NULL, "updated_at" timestamp DEFAULT now() NOT NULL, + "role" text, + "banned" boolean DEFAULT false, + "ban_reason" text, + "ban_expires" timestamp, + "last_active_at" timestamp, CONSTRAINT "user_email_unique" UNIQUE("email") ); --> statement-breakpoint CREATE TABLE "verification" ( - "id" text PRIMARY KEY NOT NULL, + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, "identifier" text NOT NULL, "value" text NOT NULL, "expires_at" timestamp NOT NULL, @@ -46,6 +60,7 @@ CREATE TABLE "verification" ( "updated_at" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint +ALTER TABLE "project" ADD CONSTRAINT "project_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint diff --git a/server/database/migrations/0001_fat_black_bird.sql b/server/database/migrations/0001_fat_black_bird.sql deleted file mode 100644 index d5eabdb..0000000 --- a/server/database/migrations/0001_fat_black_bird.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE "project" ( - "id" text PRIMARY KEY NOT NULL, - "name" text NOT NULL, - "owner_id" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "account" ALTER COLUMN "created_at" DROP DEFAULT;--> statement-breakpoint -ALTER TABLE "session" ALTER COLUMN "created_at" DROP DEFAULT;--> statement-breakpoint -ALTER TABLE "user" ALTER COLUMN "created_at" DROP DEFAULT;--> statement-breakpoint -ALTER TABLE "user" ALTER COLUMN "updated_at" DROP DEFAULT;--> statement-breakpoint -ALTER TABLE "verification" ALTER COLUMN "created_at" DROP DEFAULT;--> statement-breakpoint -ALTER TABLE "verification" ALTER COLUMN "updated_at" DROP DEFAULT;--> statement-breakpoint -ALTER TABLE "project" ADD CONSTRAINT "project_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/server/database/migrations/0002_shocking_proteus.sql b/server/database/migrations/0002_shocking_proteus.sql deleted file mode 100644 index f7f2adf..0000000 --- a/server/database/migrations/0002_shocking_proteus.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE "session" ADD COLUMN "impersonated_by" text;--> statement-breakpoint -ALTER TABLE "user" ADD COLUMN "role" text;--> statement-breakpoint -ALTER TABLE "user" ADD COLUMN "banned" boolean DEFAULT false;--> statement-breakpoint -ALTER TABLE "user" ADD COLUMN "ban_reason" text;--> statement-breakpoint -ALTER TABLE "user" ADD COLUMN "ban_expires" timestamp;--> statement-breakpoint -ALTER TABLE "user" ADD COLUMN "last_active_at" timestamp; \ No newline at end of file diff --git a/server/database/migrations/meta/0000_snapshot.json b/server/database/migrations/meta/0000_snapshot.json index 3dad245..f509cfe 100644 --- a/server/database/migrations/meta/0000_snapshot.json +++ b/server/database/migrations/meta/0000_snapshot.json @@ -1,18 +1,75 @@ { - "id": "709af286-11c5-4d75-820c-3ad308379e09", + "id": "134fd2dc-a67f-4eb8-abfc-fd9e15f46929", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", "tables": { + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_owner_id_user_id_fk": { + "name": "project_owner_id_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.account": { "name": "account", "schema": "", "columns": { "id": { "name": "id", - "type": "text", + "type": "uuid", "primaryKey": true, - "notNull": true + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" }, "account_id": { "name": "account_id", @@ -28,7 +85,7 @@ }, "user_id": { "name": "user_id", - "type": "text", + "type": "uuid", "primaryKey": false, "notNull": true }, @@ -128,9 +185,10 @@ "columns": { "id": { "name": "id", - "type": "text", + "type": "uuid", "primaryKey": true, - "notNull": true + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" }, "expires_at": { "name": "expires_at", @@ -171,9 +229,15 @@ }, "user_id": { "name": "user_id", - "type": "text", + "type": "uuid", "primaryKey": false, "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false } }, "indexes": { @@ -222,9 +286,10 @@ "columns": { "id": { "name": "id", - "type": "text", + "type": "uuid", "primaryKey": true, - "notNull": true + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" }, "name": { "name": "name", @@ -264,6 +329,37 @@ "primaryKey": false, "notNull": true, "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false } }, "indexes": {}, @@ -286,9 +382,10 @@ "columns": { "id": { "name": "id", - "type": "text", + "type": "uuid", "primaryKey": true, - "notNull": true + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" }, "identifier": { "name": "identifier", diff --git a/server/database/migrations/meta/0001_snapshot.json b/server/database/migrations/meta/0001_snapshot.json deleted file mode 100644 index d2c3728..0000000 --- a/server/database/migrations/meta/0001_snapshot.json +++ /dev/null @@ -1,411 +0,0 @@ -{ - "id": "7763d928-aa34-423c-9604-ad5be307881e", - "prevId": "709af286-11c5-4d75-820c-3ad308379e09", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.project": { - "name": "project", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owner_id": { - "name": "owner_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "project_owner_id_user_id_fk": { - "name": "project_owner_id_user_id_fk", - "tableFrom": "project", - "tableTo": "user", - "columnsFrom": ["owner_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.account": { - "name": "account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "account_userId_idx": { - "name": "account_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "session_userId_idx": { - "name": "session_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "verification_identifier_idx": { - "name": "verification_identifier_idx", - "columns": [ - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/server/database/migrations/meta/0002_snapshot.json b/server/database/migrations/meta/0002_snapshot.json deleted file mode 100644 index dbdff0d..0000000 --- a/server/database/migrations/meta/0002_snapshot.json +++ /dev/null @@ -1,448 +0,0 @@ -{ - "id": "129a191a-2146-4ce9-89a0-83279940fe26", - "prevId": "7763d928-aa34-423c-9604-ad5be307881e", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.account": { - "name": "account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "account_userId_idx": { - "name": "account_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.project": { - "name": "project", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owner_id": { - "name": "owner_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "project_owner_id_user_id_fk": { - "name": "project_owner_id_user_id_fk", - "tableFrom": "project", - "tableTo": "user", - "columnsFrom": ["owner_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "impersonated_by": { - "name": "impersonated_by", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "session_userId_idx": { - "name": "session_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "banned": { - "name": "banned", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "ban_reason": { - "name": "ban_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "ban_expires": { - "name": "ban_expires", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "last_active_at": { - "name": "last_active_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "verification_identifier_idx": { - "name": "verification_identifier_idx", - "columns": [ - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/server/database/migrations/meta/_journal.json b/server/database/migrations/meta/_journal.json index cd00e57..aac2280 100644 --- a/server/database/migrations/meta/_journal.json +++ b/server/database/migrations/meta/_journal.json @@ -5,22 +5,8 @@ { "idx": 0, "version": "7", - "when": 1769521098875, - "tag": "0000_chunky_fabian_cortez", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1773665967177, - "tag": "0001_fat_black_bird", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1773670298527, - "tag": "0002_shocking_proteus", + "when": 1780926760324, + "tag": "0000_empty_alice", "breakpoints": true } ] diff --git a/server/database/schema/app.ts b/server/database/schema/app.ts index 80229e2..e69f4fe 100644 --- a/server/database/schema/app.ts +++ b/server/database/schema/app.ts @@ -1,25 +1,25 @@ // Custom application tables - add your own tables here // This file is NOT overwritten by Better Auth CLI -import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; import { user } from "./auth"; // Helper for generic timestamp columns -const timestampColumns = { +export const timestampColumns = { createdAt: timestamp("created_at", { mode: "date", withTimezone: true }) .defaultNow() .notNull(), updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true }) .defaultNow() .notNull() - .$onUpdate(() => new Date()), + .$onUpdate(() => sql`now()`), }; -// TODO: EXAMPLE TABLE - you should remove this export const project = pgTable("project", { - id: text("id").primaryKey(), + id: uuid("id").primaryKey().defaultRandom(), name: text("name").notNull(), - ownerId: text("owner_id") + ownerId: uuid("owner_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), ...timestampColumns, diff --git a/server/database/schema/auth.ts b/server/database/schema/auth.ts index 4f7388a..7d758a5 100644 --- a/server/database/schema/auth.ts +++ b/server/database/schema/auth.ts @@ -1,15 +1,23 @@ -import { relations } from "drizzle-orm"; -import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { relations, sql } from "drizzle-orm"; +import { + boolean, + index, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; export const user = pgTable("user", { - id: text("id").primaryKey(), + id: uuid("id").default(sql`pg_catalog.gen_random_uuid()`).primaryKey(), name: text("name").notNull(), email: text("email").notNull().unique(), emailVerified: boolean("email_verified").default(false).notNull(), image: text("image"), - createdAt: timestamp("created_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") - .$onUpdate(() => new Date()) + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), role: text("role"), banned: boolean("banned").default(false), @@ -21,16 +29,16 @@ export const user = pgTable("user", { export const session = pgTable( "session", { - id: text("id").primaryKey(), + id: uuid("id").default(sql`pg_catalog.gen_random_uuid()`).primaryKey(), expiresAt: timestamp("expires_at").notNull(), token: text("token").notNull().unique(), - createdAt: timestamp("created_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") - .$onUpdate(() => new Date()) + .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), ipAddress: text("ip_address"), userAgent: text("user_agent"), - userId: text("user_id") + userId: uuid("user_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), impersonatedBy: text("impersonated_by"), @@ -41,10 +49,10 @@ export const session = pgTable( export const account = pgTable( "account", { - id: text("id").primaryKey(), + id: uuid("id").default(sql`pg_catalog.gen_random_uuid()`).primaryKey(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), - userId: text("user_id") + userId: uuid("user_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), accessToken: text("access_token"), @@ -54,9 +62,9 @@ export const account = pgTable( refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), scope: text("scope"), password: text("password"), - createdAt: timestamp("created_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") - .$onUpdate(() => new Date()) + .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, (table) => [index("account_userId_idx").on(table.userId)], @@ -65,13 +73,14 @@ export const account = pgTable( export const verification = pgTable( "verification", { - id: text("id").primaryKey(), + id: uuid("id").default(sql`pg_catalog.gen_random_uuid()`).primaryKey(), identifier: text("identifier").notNull(), value: text("value").notNull(), expiresAt: timestamp("expires_at").notNull(), - createdAt: timestamp("created_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") - .$onUpdate(() => new Date()) + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, (table) => [index("verification_identifier_idx").on(table.identifier)], diff --git a/server/features/projects/index.ts b/server/features/projects/index.ts new file mode 100644 index 0000000..aeb4282 --- /dev/null +++ b/server/features/projects/index.ts @@ -0,0 +1,11 @@ +import { createRouter } from "@/server/lib/router"; +import { createProjectRoute } from "./routes/create-project"; +import { deleteProjectRoute } from "./routes/delete-project"; +import { getProjectRoute } from "./routes/get-project"; +import { listProjectsRoute } from "./routes/list-projects"; + +export const projects = createRouter() + .route("/", listProjectsRoute) + .route("/", createProjectRoute) + .route("/", getProjectRoute) + .route("/", deleteProjectRoute); diff --git a/server/features/projects/routes/create-project.ts b/server/features/projects/routes/create-project.ts new file mode 100644 index 0000000..7c6ff29 --- /dev/null +++ b/server/features/projects/routes/create-project.ts @@ -0,0 +1,39 @@ +import { zValidator } from "@hono/zod-validator"; +import { eq } from "drizzle-orm"; +import z from "zod"; +import { project } from "@/server/database/schema"; +import { createRouter } from "@/server/lib/router"; +import { requireAuth } from "@/server/middleware/auth.middleware"; + +const PROJECT_COUNT_MAX = 3; + +const createProjectSchema = z.object({ + name: z.string().trim().min(1).max(255), +}); + +export const createProjectRoute = createRouter().post( + "/", + requireAuth, + zValidator("json", createProjectSchema), + async (c) => { + const db = c.get("db"); + const user = c.get("user"); + + const { name } = c.req.valid("json"); + + const projectCount = await db.$count(project, eq(project.ownerId, user.id)); + + if (projectCount >= PROJECT_COUNT_MAX) { + return c.apiError( + 409, + `Project limit reached. You can only create up to ${PROJECT_COUNT_MAX} projects.`, + ); + } + + const [created] = await db + .insert(project) + .values({ name, ownerId: user.id }) + .returning(); + return c.json(created, 201); + }, +); diff --git a/server/features/projects/routes/delete-project.ts b/server/features/projects/routes/delete-project.ts new file mode 100644 index 0000000..f91bc8f --- /dev/null +++ b/server/features/projects/routes/delete-project.ts @@ -0,0 +1,29 @@ +import { zValidator } from "@hono/zod-validator"; +import { and, eq } from "drizzle-orm"; +import { project } from "@/server/database/schema"; +import { createRouter } from "@/server/lib/router"; +import { requireAuth } from "@/server/middleware/auth.middleware"; +import { projectIdParamSchema } from "../validators"; + +export const deleteProjectRoute = createRouter().delete( + "/:id", + requireAuth, + zValidator("param", projectIdParamSchema), + async (c) => { + const db = c.get("db"); + const user = c.get("user"); + + const { id } = c.req.valid("param"); + + const [deleted] = await db + .delete(project) + .where(and(eq(project.ownerId, user.id), eq(project.id, id))) + .returning(); + + if (!deleted) { + return c.apiError(404, "Not found"); + } + + return c.json(deleted); + }, +); diff --git a/server/features/projects/routes/get-project.ts b/server/features/projects/routes/get-project.ts new file mode 100644 index 0000000..c45ac29 --- /dev/null +++ b/server/features/projects/routes/get-project.ts @@ -0,0 +1,28 @@ +import { zValidator } from "@hono/zod-validator"; +import { and, eq } from "drizzle-orm"; +import { project } from "@/server/database/schema"; +import { createRouter } from "@/server/lib/router"; +import { requireAuth } from "@/server/middleware/auth.middleware"; +import { projectIdParamSchema } from "../validators"; + +export const getProjectRoute = createRouter().get( + "/:id", + requireAuth, + zValidator("param", projectIdParamSchema), + async (c) => { + const db = c.get("db"); + const user = c.get("user"); + + const { id } = c.req.valid("param"); + + const found = await db.query.project.findFirst({ + where: and(eq(project.ownerId, user.id), eq(project.id, id)), + }); + + if (!found) { + return c.apiError(404, "Project not found"); + } + + return c.json(found); + }, +); diff --git a/server/features/projects/routes/list-projects.ts b/server/features/projects/routes/list-projects.ts new file mode 100644 index 0000000..52cea76 --- /dev/null +++ b/server/features/projects/routes/list-projects.ts @@ -0,0 +1,18 @@ +import { eq } from "drizzle-orm"; +import { project } from "@/server/database/schema"; +import { createRouter } from "@/server/lib/router"; +import { requireAuth } from "@/server/middleware/auth.middleware"; + +export const listProjectsRoute = createRouter().get( + "/", + requireAuth, + async (c) => { + const db = c.get("db"); + const user = c.get("user"); + const projects = await db.query.project.findMany({ + where: eq(project.ownerId, user.id), + }); + + return c.json(projects); + }, +); diff --git a/server/features/projects/validators.ts b/server/features/projects/validators.ts new file mode 100644 index 0000000..0d72f22 --- /dev/null +++ b/server/features/projects/validators.ts @@ -0,0 +1,5 @@ +import z from "zod"; + +export const projectIdParamSchema = z.object({ + id: z.uuid(), +}); diff --git a/server/lib/auth.ts b/server/lib/auth.ts index e1ad2a6..d2314c1 100644 --- a/server/lib/auth.ts +++ b/server/lib/auth.ts @@ -9,6 +9,7 @@ import * as schema from "@/server/database/schema"; export const auth = instrumentBetterAuth( betterAuth({ + advanced: { database: { generateId: "uuid" } }, database: drizzleAdapter(db, { provider: "pg", schema, diff --git a/server/lib/http.ts b/server/lib/http.ts index a082bd8..24fc57c 100644 --- a/server/lib/http.ts +++ b/server/lib/http.ts @@ -9,6 +9,10 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"; * * Use this on the *return* path — expected errors, including guards. The * unexpected path throws instead and is handled centrally by `onError`. + * + * Routes under the ambient API middleware get `c.apiError(status, message)` as + * sugar over this (see `api-error.middleware.ts`); call this form directly only + * outside that stack (e.g. the otel proxy, `app.notFound`/`app.onError`). */ export const apiError = ( c: Context, diff --git a/server/middleware/api-error.middleware.ts b/server/middleware/api-error.middleware.ts new file mode 100644 index 0000000..395c158 --- /dev/null +++ b/server/middleware/api-error.middleware.ts @@ -0,0 +1,28 @@ +import { createMiddleware } from "hono/factory"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import { apiError } from "@/server/lib/http"; + +declare module "hono" { + interface Context { + /** + * Return an expected error response: `return c.apiError(404, "Not found")`. + * Sugar over `apiError(c, …)` — same typed-return contract, so the status + * and `{ error }` body still flow into the RPC client type. Available on + * every route under the ambient API middleware stack. + */ + apiError: ( + status: S, + message: string, + ) => ReturnType>; + } +} + +/** + * Provider: binds `c.apiError` so handlers can return errors without threading + * `c` through the helper. Applied ambiently across the API. + */ +export const apiErrorMiddleware = createMiddleware(async (c, next) => { + c.apiError = (status: S, message: string) => + apiError(c, status, message); + await next(); +}); diff --git a/server/middleware/auth.middleware.ts b/server/middleware/auth.middleware.ts index 2508a0f..4c5ad52 100644 --- a/server/middleware/auth.middleware.ts +++ b/server/middleware/auth.middleware.ts @@ -1,12 +1,12 @@ import { trace } from "@opentelemetry/api"; import * as Sentry from "@sentry/bun"; import type { MiddlewareHandler } from "hono"; +import { createMiddleware } from "hono/factory"; import { auth } from "@/server/lib/auth"; -import { apiError } from "@/server/lib/http"; import { requestContext } from "@/server/lib/request-context"; export type AuthMiddlewareVariables = { - user: typeof auth.$Infer.Session.user; + user?: typeof auth.$Infer.Session.user; }; /** @@ -43,12 +43,12 @@ export const sessionMiddleware: MiddlewareHandler = async (c, next) => { * Apply per-route (not globally) on the routes that need protection. Relies on * the ambient sessionMiddleware (or test auth middleware) having run first. */ -export const requireAuth: MiddlewareHandler = async (c, next) => { - const user = c.get("user"); - - if (!user) { - return apiError(c, 401, "Unauthorized"); +export const requireAuth = createMiddleware<{ + Variables: { user: typeof auth.$Infer.Session.user }; +}>(async (c, next) => { + if (!c.get("user")) { + return c.apiError(401, "Unauthorized"); } await next(); -}; +}); diff --git a/server/server.ts b/server/server.ts index 1203ec4..417aed2 100644 --- a/server/server.ts +++ b/server/server.ts @@ -14,9 +14,11 @@ import { authFeature } from "@/server/features/auth"; import { demo } from "@/server/features/demo"; import { health } from "@/server/features/health"; import { otel } from "@/server/features/otel"; +import { projects } from "@/server/features/projects"; import { apiError } from "@/server/lib/http"; import { logger } from "@/server/lib/logger"; import { createRouter } from "@/server/lib/router"; +import { apiErrorMiddleware } from "@/server/middleware/api-error.middleware"; import { sessionMiddleware } from "@/server/middleware/auth.middleware"; import { dbMiddleware } from "@/server/middleware/db.middleware"; @@ -34,18 +36,19 @@ if (env.VITEST == null) { // API routes — traced and exposed via RPC. // -// Middleware layering (see server/CONVENTIONS.md): +// Middleware layering (see server/README.md): // - Ambient providers run on every API route and make no access decision: // they only populate context (optional user, db). // - Access decisions are per-route guards (e.g. `requireAuth` on a route), // never applied globally — so they can't leak onto sibling routes. const api = createRouter() // Ambient providers - .use(sessionMiddleware, dbMiddleware) + .use(apiErrorMiddleware, sessionMiddleware, dbMiddleware) // Features — each owns a prefix; protection is declared per-route inside it .route("/auth", authFeature) .route("/health", health) - .route("/demo", demo); + .route("/demo", demo) + .route("/projects", projects); const app = new Hono() // OTel proxy must be BEFORE tracing middleware (avoids recursive tracing) @@ -91,9 +94,9 @@ app.onError((err, c) => { }); // Static file serving and SPA fallback -const isProd = env.ENV !== "development"; +const isProduction = env.ENV !== "development"; -if (isProd) { +if (isProduction) { app.use( "*", serveStatic({ @@ -110,7 +113,7 @@ if (isProd) { // SPA fallback: serve index.html for any unmatched routes app.get("*", async (c) => { const html = await Bun.file( - isProd ? "./dist-static/index.html" : "./index.html", + isProduction ? "./dist-static/index.html" : "./index.html", ).text(); return c.html(html); }); diff --git a/server/utils/testing/test-app.ts b/server/utils/testing/test-app.ts index cb52062..5c36ad1 100644 --- a/server/utils/testing/test-app.ts +++ b/server/utils/testing/test-app.ts @@ -1,5 +1,6 @@ import type { Hono } from "hono"; import { type AppEnv, createRouter } from "@/server/lib/router"; +import { apiErrorMiddleware } from "@/server/middleware/api-error.middleware"; import testAuthMiddleware from "@/server/utils/testing/test-auth.middleware"; import { createTestDb } from "@/server/utils/testing/test-db"; import { createTestDbMiddleware } from "@/server/utils/testing/test-db.middleware"; @@ -26,6 +27,7 @@ export async function getTestApp( // Create fresh app instance to inject the db middleware const testApp = createRouter(); + testApp.use(apiErrorMiddleware); testApp.use(createTestDbMiddleware(db)); testApp.use(testAuthMiddleware); testApp.route("/", app); diff --git a/server/utils/testing/test-users.ts b/server/utils/testing/test-users.ts index a66a491..b1d6c5e 100644 --- a/server/utils/testing/test-users.ts +++ b/server/utils/testing/test-users.ts @@ -1,17 +1,19 @@ import type { InsertUser } from "@/server/utils/testing/test-user"; -export const TestUser1: InsertUser = { +export const TestUser1 = { id: "00000000-0000-4000-8000-000000000001", email: "user1@example.com", name: "Test User 1", createdAt: new Date(), -}; + lastActiveAt: new Date(), +} satisfies InsertUser; -export const TestUser2: InsertUser = { +export const TestUser2 = { id: "00000000-0000-4000-8000-000000000002", email: "user2@example.com", name: "Test User 2", createdAt: new Date(), -}; + lastActiveAt: new Date(), +} satisfies InsertUser; export const testUsers = [TestUser1, TestUser2]; diff --git a/web/app.tsx b/web/app.tsx index af2688e..e12ec58 100644 --- a/web/app.tsx +++ b/web/app.tsx @@ -2,6 +2,7 @@ import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { AuthDemo } from "~/components/auth-demo"; +import { ProjectsDemo } from "~/components/projects-demo"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import i18n from "./i18n/i18n"; @@ -83,6 +84,7 @@ export default function App() { {t("switchLang")} + ); } diff --git a/web/components/auth-demo.tsx b/web/components/auth-demo.tsx index 2f227cf..b510282 100644 --- a/web/components/auth-demo.tsx +++ b/web/components/auth-demo.tsx @@ -1,9 +1,11 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button } from "~/components/ui/button"; import { signIn, signOut, signUp, useSession } from "~/lib/auth-client"; import { withSpan } from "~/tracing"; export function AuthDemo() { + const { t } = useTranslation("common"); const { data: session, isPending } = useSession(); const [email, setEmail] = useState("demo@example.com"); const [password, setPassword] = useState("password123"); @@ -14,92 +16,92 @@ export function AuthDemo() { const handleSignUp = () => withSpan("auth.sign_up", { component: "AuthDemo" }, async () => { setError(null); - setStatus("Signing up..."); + setStatus(t("signingUp")); const result = await signUp.email({ email, password, name }); if (result.error) { - setError(result.error.message ?? "Signup failed"); + setError(result.error.message ?? t("signupFailed")); setStatus(null); } else { - setStatus("Signed up successfully!"); + setStatus(t("signedUpSuccess")); } }); const handleSignIn = () => withSpan("auth.sign_in", { component: "AuthDemo" }, async () => { setError(null); - setStatus("Signing in..."); + setStatus(t("signingIn")); const result = await signIn.email({ email, password }); if (result.error) { - setError(result.error.message ?? "Sign in failed"); + setError(result.error.message ?? t("signinFailed")); setStatus(null); } else { - setStatus("Signed in successfully!"); + setStatus(t("signedInSuccess")); } }); const handleSignOut = () => withSpan("auth.sign_out", { component: "AuthDemo" }, async () => { setError(null); - setStatus("Signing out..."); + setStatus(t("signingOut")); await signOut(); - setStatus("Signed out successfully!"); + setStatus(t("signedOutSuccess")); }); if (isPending) { - return
Loading session...
; + return
{t("loadingSession")}
; } return (
-

Auth Demo

+

{t("authDemoTitle")}

{session ? (
-

Signed in as:

+

{t("signedInAs")}

{session.user.name}

{session.user.email}

) : (
setName(e.target.value)} className="rounded border px-3 py-2 text-sm" /> setEmail(e.target.value)} className="rounded border px-3 py-2 text-sm" /> setPassword(e.target.value)} className="rounded border px-3 py-2 text-sm" />
@@ -108,9 +110,7 @@ export function AuthDemo() { {status &&

{status}

} {error &&

{error}

} -

- Auth actions are wrapped in OpenTelemetry spans. Check Axiom for traces! -

+

{t("authSpanNote")}

); } diff --git a/web/components/projects-demo.tsx b/web/components/projects-demo.tsx new file mode 100644 index 0000000..c35ea34 --- /dev/null +++ b/web/components/projects-demo.tsx @@ -0,0 +1,109 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { api } from "~/lib/api"; +import { useSession } from "~/lib/auth-client"; + +export function ProjectsDemo() { + const { t } = useTranslation("common"); + const { data: session } = useSession(); + const queryClient = useQueryClient(); + const [name, setName] = useState(""); + + // Skipped until signed in — the routes require auth. + const projectsQuery = useQuery( + api.projects.$get.queryOptions({ enabled: !!session }), + ); + + const createProject = useMutation( + api.projects.$post.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: api.projects.$get.queryOptions({}).queryKey, + }); + setName(""); + }, + }), + ); + + const deleteProject = useMutation( + api.projects[":id"].$delete.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: api.projects.$get.queryOptions({}).queryKey, + }); + // Deleting frees a slot, so a prior "limit reached" no longer applies. + createProject.reset(); + }, + }), + ); + + // `data` is now the project only — errors arrive on `error` as a typed + // ApiError. Its `status` is a literal union (e.g. 409) and narrows `body`. + const createError = createProject.error?.body.error ?? null; + + if (!session) { + return ( +
+

{t("projectsTitle")}

+

+ {t("projectsSignInPrompt")} +

+
+ ); + } + + return ( +
+

{t("projectsTitle")}

+ +
+ { + setName(event.target.value); + // Clear a stale "limit reached" error once they start retrying. + if (createProject.isError) createProject.reset(); + }} + disabled={createProject.isPending} + /> + +
+ + {createError &&

{createError}

} + + {projectsQuery.isPending ? ( +

{t("loading")}

+ ) : ( +
    + {projectsQuery.data?.map((project) => ( +
  • + {project.name} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/web/i18n/locales/en/common.ts b/web/i18n/locales/en/common.ts index 6583254..acbacac 100644 --- a/web/i18n/locales/en/common.ts +++ b/web/i18n/locales/en/common.ts @@ -5,4 +5,33 @@ export default { sayHello: "Say Hello", loading: "Loading...", switchLang: "Switch Language", + + // Projects demo + projectsTitle: "Projects", + projectsSignInPrompt: "Sign in to see your projects.", + projectNamePlaceholder: "Project name", + creating: "Creating...", + create: "Create", + delete: "Delete", + + // Auth demo + authDemoTitle: "Auth Demo", + loadingSession: "Loading session...", + signedInAs: "Signed in as:", + signOut: "Sign Out", + signUp: "Sign Up", + signIn: "Sign In", + authNamePlaceholder: "Name", + authEmailPlaceholder: "Email", + authPasswordPlaceholder: "Password", + signingUp: "Signing up...", + signupFailed: "Signup failed", + signedUpSuccess: "Signed up successfully!", + signingIn: "Signing in...", + signinFailed: "Sign in failed", + signedInSuccess: "Signed in successfully!", + signingOut: "Signing out...", + signedOutSuccess: "Signed out successfully!", + authSpanNote: + "Auth actions are wrapped in OpenTelemetry spans. Check Axiom for traces!", } as const; diff --git a/web/i18n/locales/sl/common.ts b/web/i18n/locales/sl/common.ts index 13a7046..5509f1e 100644 --- a/web/i18n/locales/sl/common.ts +++ b/web/i18n/locales/sl/common.ts @@ -5,4 +5,33 @@ export default { sayHello: "Pozdravi", loading: "Nalagam...", switchLang: "Zamenjaj jezik", + + // Projects demo + projectsTitle: "Projekti", + projectsSignInPrompt: "Prijavi se za ogled svojih projektov.", + projectNamePlaceholder: "Ime projekta", + creating: "Ustvarjam...", + create: "Ustvari", + delete: "Izbriši", + + // Auth demo + authDemoTitle: "Demo avtentikacije", + loadingSession: "Nalagam sejo...", + signedInAs: "Prijavljen kot:", + signOut: "Odjava", + signUp: "Registracija", + signIn: "Prijava", + authNamePlaceholder: "Ime", + authEmailPlaceholder: "E-pošta", + authPasswordPlaceholder: "Geslo", + signingUp: "Registriram...", + signupFailed: "Registracija ni uspela", + signedUpSuccess: "Uspešna registracija!", + signingIn: "Prijavljam...", + signinFailed: "Prijava ni uspela", + signedInSuccess: "Uspešna prijava!", + signingOut: "Odjavljam...", + signedOutSuccess: "Uspešna odjava!", + authSpanNote: + "Avtentikacijska dejanja so ovita v OpenTelemetry spane. Sledi preveri v Axiomu!", } as const; diff --git a/web/lib/api-error.ts b/web/lib/api-error.ts new file mode 100644 index 0000000..c373525 --- /dev/null +++ b/web/lib/api-error.ts @@ -0,0 +1,27 @@ +/** + * Error raised at the client seam when the backend returns a non-ok response. + * + * The backend `return`s its expected errors as values (`apiError(c, 409, …)`), + * so they live in the Hono RPC type. `safeFetch` re-throws them as `ApiError` + * so they flow through TanStack's error channel — while the status code and + * body shape stay typed (see `ApiErrorUnionOf` in `typed-client.ts`). + */ +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +export class ApiError< + Status extends ContentfulStatusCode = ContentfulStatusCode, + Body = unknown, +> extends Error { + readonly status: Status; + readonly body: Body; + + constructor(status: Status, body: Body) { + super(`API error ${status}`); + this.name = "ApiError"; + this.status = status; + this.body = body; + } +} + +export const isApiError = (error: unknown): error is ApiError => + error instanceof ApiError; diff --git a/web/lib/api.ts b/web/lib/api.ts index ee8c855..f4dcf43 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -1,6 +1,6 @@ import { hc } from "hono/client"; -import { hcQuery } from "hono-rpc-query"; import type { AppType } from "@/server/server"; +import { hcQueryTyped } from "./typed-client"; // Precompile the RPC client type so tsc instantiates hc once, instead // of tsserver re-instantiating it on every use. @@ -9,4 +9,4 @@ import type { AppType } from "@/server/server"; type Client = ReturnType>; const client: Client = hc("/api"); -export const api = hcQuery(client); +export const api = hcQueryTyped(client); diff --git a/web/lib/typed-client.ts b/web/lib/typed-client.ts new file mode 100644 index 0000000..378903e --- /dev/null +++ b/web/lib/typed-client.ts @@ -0,0 +1,196 @@ +import type { + QueryFunction, + QueryKey, + UseMutationOptions, + UseQueryOptions, +} from "@tanstack/react-query"; +import type { + ClientRequestOptions, + ClientResponse, + InferRequestType, + InferResponseType, +} from "hono/client"; +import type { + ContentfulStatusCode, + SuccessStatusCode, +} from "hono/utils/http-status"; +import { ApiError } from "./api-error"; + +/** + * Any Hono client endpoint (`$get`, `$post`, …). `never` parameters make this + * the supertype of every endpoint signature (parameters are contravariant); + * `safeFetch` owns the one cast back to the concrete input type. + */ +type Endpoint = ( + args: never, + options?: ClientRequestOptions, +) => Promise>; + +/** The success body — what `data` resolves to. */ +export type SuccessOf = InferResponseType; + +/** + * One `ApiError` per error response variant. Hono types an endpoint's return + * as a union of `ClientResponse` — one member per `return` in + * the handler — so distributing over it pairs each error status with its body. + * + * A statusless `c.json(x)` is typed with the whole `ContentfulStatusCode` + * union instead of a literal; the first branch drops those so a success body + * never masquerades as an error variant. + */ +type ErrorResponseOf = + Response extends ClientResponse< + infer Body, + infer Status extends ContentfulStatusCode + > + ? ContentfulStatusCode extends Status + ? never + : Status extends SuccessStatusCode + ? never + : ApiError + : never; + +/** + * The typed error channel. A handler that `return apiError(c, 409, …)` makes + * this include `ApiError<409, { error: string }>`; add a `return apiError(c, + * 403, …)` and `ApiError<403, …>` appears here too. Endpoints that declare no + * error responses fall back to bare `ApiError` — guard 401s, `onError` 500s, + * and other unexpected failures still throw at runtime. (Transport failures + * bypass this entirely and surface as `TypeError`.) + */ +export type ApiErrorUnionOf = [ + ErrorResponseOf>>, +] extends [never] + ? ApiError + : ErrorResponseOf>>; + +/** + * Call a raw Hono endpoint, narrow on `response.ok`, and either return the + * typed success body or throw a typed `ApiError`. This is the seam where + * Hono's return-grain becomes TanStack's throw-grain. + */ +async function safeFetch( + endpoint: T, + args: InferRequestType, + opts?: { signal?: AbortSignal }, +): Promise> { + // `Endpoint` erases the parameter to `never`; the concrete type is `args`'s. + const response = await endpoint(args as never, { + init: { signal: opts?.signal }, + }); + + if (!response.ok) { + const body = await response + .json() + .catch(() => ({ error: "Unknown error" })); + // A non-ok response always carries a contentful status; `Endpoint` only + // erased it to `number`. + throw new ApiError(response.status as ContentfulStatusCode, body); + } + + return (await response.json()) as SuccessOf; +} + +type InputArg = + // biome-ignore lint/complexity/noBannedTypes: "no input" is genuinely the empty object here + {} extends InferRequestType + ? { input?: undefined } + : { input: InferRequestType }; + +/** + * `queryOptions` / `mutationOptions` take TanStack passthrough options and + * return the fully typed options object. Declaring the *return* type as + * `UseQueryOptions` (not a bare `{ queryKey, queryFn }`) is + * load-bearing: `useQuery`/`useMutation` infer `TError` only from the declared + * type of the options they receive — there is no tag- or throw-based error + * inference on the hook side. + */ +interface QueryEndpoint { + call: T; + queryOptions: ( + args: Omit< + UseQueryOptions, ApiErrorUnionOf>, + "queryKey" | "queryFn" + > & + InputArg, + ) => UseQueryOptions, ApiErrorUnionOf> & { + queryKey: QueryKey; + queryFn: QueryFunction>; + }; + mutationOptions: ( + args: Omit< + UseMutationOptions< + SuccessOf, + ApiErrorUnionOf, + InferRequestType, + TContext + >, + "mutationKey" | "mutationFn" + >, + ) => UseMutationOptions< + SuccessOf, + ApiErrorUnionOf, + InferRequestType, + TContext + > & { + mutationKey: QueryKey; + mutationFn: (input: InferRequestType) => Promise>; + }; +} + +function buildEndpoint( + endpoint: T, + path: string[], +): QueryEndpoint { + return { + call: endpoint, + queryOptions(args) { + const { input, ...rest } = args; + return { + ...rest, + queryKey: [path, { type: "query", input }], + queryFn: ({ signal }) => + safeFetch(endpoint, (input ?? {}) as InferRequestType, { signal }), + }; + }, + mutationOptions(args) { + return { + ...args, + mutationKey: [path, { type: "mutation" }], + mutationFn: (input) => safeFetch(endpoint, input), + }; + }, + }; +} + +type TypedClient = { + [K in keyof T]: T[K] extends Endpoint + ? QueryEndpoint + : T[K] extends object + ? TypedClient + : T[K]; +}; + +const HTTP_METHODS = ["$get", "$post", "$put", "$patch", "$delete"]; + +/** + * Wrap a Hono RPC client so each endpoint exposes `queryOptions` / + * `mutationOptions` with clean success `data` and a typed `ApiError` channel. + */ +export function hcQueryTyped(client: T): TypedClient { + const proxy = (target: T, path: string[] = []): TypedClient => + new Proxy(target, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof property !== "string" || property === "then") return value; + + const nextPath = [...path, property]; + if (HTTP_METHODS.includes(property)) { + return buildEndpoint(value as Endpoint, nextPath); + } + return proxy(value as T, nextPath); + }, + }) as TypedClient; + + return proxy(client); +}