From 613be44aac826bee8ae812fe9c4b43553babdf61 Mon Sep 17 00:00:00 2001 From: PAMulligan Date: Fri, 12 Jun 2026 21:03:00 -0400 Subject: [PATCH] feat: add blog API Node.js/Docker example Complete working example for the Node.js/Docker deployment path, complementing the Cloudflare Workers todo example with the features a realistic API needs beyond basic CRUD: - users -> posts -> comments with Drizzle relations, cascade deletes, and embedded public author objects - JWT auth with jose (HS256, issuer/audience validation, 15m access + 7d refresh rotation); passwords hashed with Node's built-in scrypt - offset pagination with a meta envelope on every list endpoint - Zod validation on all bodies, queries, and params with structured VALIDATION_ERROR details - 56 Vitest tests; integration tests run the real app against PGlite (in-process Postgres) and apply the committed migrations, so pnpm test needs no Docker or external database - docker compose up builds the image, starts PostgreSQL 17, applies migrations via a one-shot job running the compiled migrator inside the production image, and health-checks the API - packageManager pinned so the in-image pnpm matches the committed lockfile (pnpm@latest's minimumReleaseAge policy rejects same-day dependency releases at image build time) Generated with scripts/setup-project.sh blog-api-node --node, then extended; the starter layout, configs, Dockerfile, and quality gates are kept as generated. Fixes #19 Co-Authored-By: Claude Fable 5 --- README.md | 4 +- examples/blog-api-node/README.md | 237 ++ examples/blog-api-node/api/.env.example | 10 + examples/blog-api-node/api/.gitignore | 11 + examples/blog-api-node/api/Dockerfile | 54 + examples/blog-api-node/api/docker-compose.yml | 81 + examples/blog-api-node/api/drizzle.config.ts | 12 + examples/blog-api-node/api/eslint.config.js | 53 + examples/blog-api-node/api/package.json | 45 + examples/blog-api-node/api/pnpm-lock.yaml | 2706 +++++++++++++++++ examples/blog-api-node/api/prettier.config.js | 13 + examples/blog-api-node/api/src/app.ts | 56 + examples/blog-api-node/api/src/config.ts | 19 + examples/blog-api-node/api/src/db/client.ts | 20 + examples/blog-api-node/api/src/db/migrate.ts | 22 + .../api/src/db/migrations/0000_init.sql | 33 + .../src/db/migrations/meta/0000_snapshot.json | 264 ++ .../api/src/db/migrations/meta/_journal.json | 13 + examples/blog-api-node/api/src/db/schema.ts | 68 + examples/blog-api-node/api/src/db/seed.ts | 81 + examples/blog-api-node/api/src/index.ts | 51 + examples/blog-api-node/api/src/lib/errors.ts | 26 + .../blog-api-node/api/src/lib/pagination.ts | 20 + .../blog-api-node/api/src/lib/password.ts | 33 + examples/blog-api-node/api/src/lib/tokens.ts | 54 + .../blog-api-node/api/src/lib/validate.ts | 31 + .../blog-api-node/api/src/middleware/auth.ts | 35 + examples/blog-api-node/api/src/routes/auth.ts | 106 + .../blog-api-node/api/src/routes/comments.ts | 97 + .../blog-api-node/api/src/routes/health.ts | 37 + .../blog-api-node/api/src/routes/posts.ts | 132 + .../blog-api-node/api/src/routes/users.ts | 53 + examples/blog-api-node/api/src/types.ts | 16 + examples/blog-api-node/api/tests/helpers.ts | 111 + .../api/tests/integration/auth.test.ts | 206 ++ .../api/tests/integration/comments.test.ts | 190 ++ .../api/tests/integration/posts.test.ts | 258 ++ examples/blog-api-node/api/tests/setup.ts | 10 + .../api/tests/unit/health.test.ts | 46 + .../api/tests/unit/pagination.test.ts | 28 + .../api/tests/unit/password.test.ts | 25 + .../api/tests/unit/tokens.test.ts | 29 + examples/blog-api-node/api/tsconfig.base.json | 42 + examples/blog-api-node/api/tsconfig.json | 8 + examples/blog-api-node/api/vitest.config.ts | 46 + 45 files changed, 5491 insertions(+), 1 deletion(-) create mode 100644 examples/blog-api-node/README.md create mode 100644 examples/blog-api-node/api/.env.example create mode 100644 examples/blog-api-node/api/.gitignore create mode 100644 examples/blog-api-node/api/Dockerfile create mode 100644 examples/blog-api-node/api/docker-compose.yml create mode 100644 examples/blog-api-node/api/drizzle.config.ts create mode 100644 examples/blog-api-node/api/eslint.config.js create mode 100644 examples/blog-api-node/api/package.json create mode 100644 examples/blog-api-node/api/pnpm-lock.yaml create mode 100644 examples/blog-api-node/api/prettier.config.js create mode 100644 examples/blog-api-node/api/src/app.ts create mode 100644 examples/blog-api-node/api/src/config.ts create mode 100644 examples/blog-api-node/api/src/db/client.ts create mode 100644 examples/blog-api-node/api/src/db/migrate.ts create mode 100644 examples/blog-api-node/api/src/db/migrations/0000_init.sql create mode 100644 examples/blog-api-node/api/src/db/migrations/meta/0000_snapshot.json create mode 100644 examples/blog-api-node/api/src/db/migrations/meta/_journal.json create mode 100644 examples/blog-api-node/api/src/db/schema.ts create mode 100644 examples/blog-api-node/api/src/db/seed.ts create mode 100644 examples/blog-api-node/api/src/index.ts create mode 100644 examples/blog-api-node/api/src/lib/errors.ts create mode 100644 examples/blog-api-node/api/src/lib/pagination.ts create mode 100644 examples/blog-api-node/api/src/lib/password.ts create mode 100644 examples/blog-api-node/api/src/lib/tokens.ts create mode 100644 examples/blog-api-node/api/src/lib/validate.ts create mode 100644 examples/blog-api-node/api/src/middleware/auth.ts create mode 100644 examples/blog-api-node/api/src/routes/auth.ts create mode 100644 examples/blog-api-node/api/src/routes/comments.ts create mode 100644 examples/blog-api-node/api/src/routes/health.ts create mode 100644 examples/blog-api-node/api/src/routes/posts.ts create mode 100644 examples/blog-api-node/api/src/routes/users.ts create mode 100644 examples/blog-api-node/api/src/types.ts create mode 100644 examples/blog-api-node/api/tests/helpers.ts create mode 100644 examples/blog-api-node/api/tests/integration/auth.test.ts create mode 100644 examples/blog-api-node/api/tests/integration/comments.test.ts create mode 100644 examples/blog-api-node/api/tests/integration/posts.test.ts create mode 100644 examples/blog-api-node/api/tests/setup.ts create mode 100644 examples/blog-api-node/api/tests/unit/health.test.ts create mode 100644 examples/blog-api-node/api/tests/unit/pagination.test.ts create mode 100644 examples/blog-api-node/api/tests/unit/password.test.ts create mode 100644 examples/blog-api-node/api/tests/unit/tokens.test.ts create mode 100644 examples/blog-api-node/api/tsconfig.base.json create mode 100644 examples/blog-api-node/api/tsconfig.json create mode 100644 examples/blog-api-node/api/vitest.config.ts diff --git a/README.md b/README.md index 0fadb6b..72f5a8f 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,9 @@ pnpm install cd api && pnpm dev ``` -> **New to Nerva?** See the [Todo API example](examples/todo-api-cloudflare/) for a complete working Cloudflare Workers project with CRUD, D1, Zod validation, and tests. +> **New to Nerva?** Two complete working examples show both deployment paths: +> - [Todo API example](examples/todo-api-cloudflare/) — Cloudflare Workers (edge) with CRUD, D1, Zod validation, and tests. +> - [Blog API example](examples/blog-api-node/) — Node.js/Docker (server) with related resources (users → posts → comments), JWT auth with refresh tokens, offset pagination, PostgreSQL + Drizzle migrations, and Docker Compose orchestration. ## Pipelines diff --git a/examples/blog-api-node/README.md b/examples/blog-api-node/README.md new file mode 100644 index 0000000..bcedc7a --- /dev/null +++ b/examples/blog-api-node/README.md @@ -0,0 +1,237 @@ +# Blog API — Node.js / Docker Example + +A complete, working blog API built with [Nerva](../../README.md) for the **Node.js / Docker** deployment target. Where the [Todo API example](../todo-api-cloudflare/) shows the edge path (Cloudflare Workers + D1), this example covers the traditional server path and the features a realistic API needs beyond basic CRUD: + +- **Related resources** — users → posts → comments with Drizzle relations, cascading deletes, and embedded authors in responses +- **JWT authentication** — register, login, and refresh-token rotation (`jose`, HS256, issuer/audience validation), passwords hashed with Node's built-in scrypt +- **Pagination** — offset-based `?limit=&offset=` with a `meta` envelope on every list endpoint +- **PostgreSQL + Drizzle** — schema as the single source of truth, committed SQL migrations +- **Zod validation** — every body, query, and path param validated, with structured error details +- **Docker orchestration** — one `docker compose up` builds the image, starts PostgreSQL, applies migrations, and health-checks the API + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) with Compose v2 (`docker compose`) +- [pnpm](https://pnpm.io/installation) and Node.js 22+ — only needed for local development and running the tests + +## Quick start + +```bash +cd examples/blog-api-node/api + +docker compose up -d --build # postgres → migrations → API +docker compose run --rm migrate node dist/db/seed.js # optional: demo users + posts + +curl http://localhost:3000/health +``` + +The compose file starts three services: `postgres` (PostgreSQL 17), `migrate` (a one-shot job that applies the committed Drizzle migrations, then exits), and `api` (started only after migrations complete, gated by a `/health` check). The migrate job runs the compiled migration runner ([`api/src/db/migrate.ts`](api/src/db/migrate.ts)) inside the same production image as the API — `drizzle-orm` ships the migrator as a runtime dependency, so the pruned image needs no dev tooling. + +The seed creates two accounts you can log in with immediately: + +| Email | Password | +|-------|----------| +| `alice@example.com` | `password123` | +| `bob@example.com` | `password123` | + +Tear down with `docker compose down` (add `-v` to also drop the database volume). + +> Ports busy? Override them: `DB_PORT=5433 PORT=3001 docker compose up -d --build`. The API is pinned to the pnpm release in `package.json` (`packageManager`), so the Docker build resolves dependencies exactly like the committed lockfile. + +## API at a glance + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/health` | — | Service + database status | +| POST | `/auth/register` | — | Create an account, returns user + token pair | +| POST | `/auth/login` | — | Exchange credentials for a token pair | +| POST | `/auth/refresh` | — | Exchange a refresh token for a new pair | +| GET | `/auth/me` | Bearer | The caller's own profile (includes email) | +| GET | `/users/:id` | — | Public profile (no email) | +| GET | `/users/:id/posts` | — | A user's posts, paginated | +| GET | `/posts` | — | All posts with author, newest first, paginated | +| GET | `/posts/:id` | — | One post with author | +| POST | `/posts` | Bearer | Create a post | +| PATCH | `/posts/:id` | Bearer (author) | Partial update (title and/or content) | +| DELETE | `/posts/:id` | Bearer (author) | Delete a post (comments cascade) | +| GET | `/posts/:id/comments` | — | Comments on a post, oldest first, paginated | +| POST | `/posts/:id/comments` | Bearer | Comment on a post | +| DELETE | `/comments/:id` | Bearer (author) | Delete own comment | + +## Example requests + +Register and capture the tokens: + +```bash +curl -s -X POST http://localhost:3000/auth/register \ + -H 'Content-Type: application/json' \ + -d '{"email":"carol@example.com","name":"Carol","password":"password123"}' +``` + +```json +{ + "data": { + "user": { + "id": "5f0c…", + "email": "carol@example.com", + "name": "Carol", + "createdAt": "2026-06-13T01:00:00.000Z" + }, + "accessToken": "eyJhbGci…", + "refreshToken": "eyJhbGci…" + } +} +``` + +Create a post (access tokens expire after 15 minutes; refresh tokens after 7 days): + +```bash +TOKEN="eyJhbGci…" # accessToken from register/login +curl -s -X POST http://localhost:3000/posts \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"title":"Hello","content":"My first post."}' +``` + +List posts with pagination — every list response carries a `meta` block: + +```bash +curl -s 'http://localhost:3000/posts?limit=2&offset=0' +``` + +```json +{ + "data": [ + { + "id": "9a31…", + "title": "Hello", + "content": "My first post.", + "author": { "id": "5f0c…", "name": "Carol" }, + "createdAt": "2026-06-13T01:01:00.000Z", + "updatedAt": "2026-06-13T01:01:00.000Z", + "authorId": "5f0c…" + } + ], + "meta": { "total": 4, "limit": 2, "offset": 0 } +} +``` + +Comment on a post, then refresh the session when the access token expires: + +```bash +curl -s -X POST http://localhost:3000/posts/9a31…/comments \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"body":"Nice post!"}' + +curl -s -X POST http://localhost:3000/auth/refresh \ + -H 'Content-Type: application/json' \ + -d '{"refreshToken":"eyJhbGci…"}' +``` + +Errors always use the same envelope (codes: `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, `INTERNAL_ERROR`): + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Request validation failed", + "details": [{ "field": "password", "message": "Too small: expected string to have >=8 characters" }] + } +} +``` + +## Database schema + +``` +┌─────────────────────────┐ +│ users │ +│─────────────────────────│ +│ id uuid PK │ +│ email text UQ │ +│ name text │ +│ password_hash text │ +│ created_at timestamptz │ +│ updated_at timestamptz │ +└───────┬───────────┬─────┘ + │ 1 │ 1 + │ └────────────────────────┐ + │ * (author_id, cascade) │ * (author_id, cascade) +┌───────┴─────────────────┐ ┌───────┴─────────────────┐ +│ posts │ │ comments │ +│─────────────────────────│ │─────────────────────────│ +│ id uuid PK │ │ id uuid PK │ +│ author_id uuid FK │ 1 * │ post_id uuid FK │ +│ title text ├──────────┤ author_id uuid FK │ +│ content text │ (cascade)│ body text │ +│ created_at timestamptz │ │ created_at timestamptz │ +│ updated_at timestamptz │ └─────────────────────────┘ +└─────────────────────────┘ +``` + +Deleting a user removes their posts and comments; deleting a post removes its comments — enforced by `ON DELETE CASCADE` in the database, not application code. The schema lives in [`api/src/db/schema.ts`](api/src/db/schema.ts); the SQL migration generated from it is committed under [`api/src/db/migrations/`](api/src/db/migrations/). + +## Local development (without the API container) + +```bash +cd examples/blog-api-node/api +pnpm install +docker compose up -d postgres # just the database +cp .env.example .env +pnpm db:migrate # apply migrations +pnpm db:seed # optional demo data +pnpm dev # http://localhost:3000, reloads on save +``` + +## Testing + +```bash +pnpm test # run once +pnpm test:watch # watch mode +pnpm test:coverage # enforces 80% thresholds +``` + +The integration tests run against **PGlite** — a real in-process PostgreSQL — so `pnpm test` needs **no Docker and no running database**. The test harness ([`api/tests/helpers.ts`](api/tests/helpers.ts)) applies the same committed migrations the compose stack uses, which means the migration SQL itself is exercised on every test run. The app is built around an injected database (`createApp(db)` in [`api/src/app.ts`](api/src/app.ts)), so production and tests run identical application code on different drivers. + +56 tests cover the full auth lifecycle (register → login → refresh → protected routes, duplicate emails, tampered/expired/cross-type tokens, deleted accounts), ownership rules (403s), pagination windows and meta, validation details, cascading deletes, and the health check (healthy + degraded). + +## Project structure + +``` +api/ +├── src/ +│ ├── index.ts # Entry: real postgres-js pool + graceful shutdown +│ ├── app.ts # createApp(db): middleware, routes, error envelope +│ ├── config.ts # Zod-validated environment +│ ├── db/ +│ │ ├── schema.ts # users / posts / comments + relations (source of truth) +│ │ ├── client.ts # postgres-js client factory + Database type +│ │ ├── migrations/ # Committed SQL generated by drizzle-kit +│ │ ├── migrate.ts # Deploy-time migration runner (compose migrate service) +│ │ └── seed.ts # Demo data (pnpm db:seed) +│ ├── lib/ +│ │ ├── tokens.ts # jose JWT sign/verify (access + refresh) +│ │ ├── password.ts # scrypt hash/verify (no native deps) +│ │ ├── pagination.ts # limit/offset schema + meta builder +│ │ ├── validate.ts # zValidator wrapper with the error envelope +│ │ └── errors.ts # apiError(c, status, code, message) +│ ├── middleware/ +│ │ └── auth.ts # requireAuth: Bearer → verified user in context +│ └── routes/ # health, auth, users, posts, comments +├── tests/ +│ ├── helpers.ts # PGlite test database + register/post factories +│ ├── unit/ # tokens, password, pagination, health +│ └── integration/ # auth flows, posts, comments (full HTTP cycles) +├── Dockerfile # Multi-stage: build → pruned production image +└── docker-compose.yml # api + migrate (one-shot) + postgres +``` + +## How this was built + +The project started from the Nerva generator and kept its layout, configs, Dockerfile, and quality gates: + +```bash +./scripts/setup-project.sh blog-api-node --node +``` + +On top of the generated starter: the schema grew two related tables, auth (jose + scrypt) and the routes were added, the entry point was refactored into an injectable `createApp(db)` factory for testability, a `migrate` one-shot service joined the compose file, and the soft-delete starter snippets were removed to keep the example focused. Everything else — strict TypeScript, ESLint/Prettier, Vitest with coverage thresholds, the multi-stage Dockerfile — is stock Nerva. diff --git a/examples/blog-api-node/api/.env.example b/examples/blog-api-node/api/.env.example new file mode 100644 index 0000000..9a35c73 --- /dev/null +++ b/examples/blog-api-node/api/.env.example @@ -0,0 +1,10 @@ +# Local development (pnpm dev against the compose postgres service). +# docker compose up needs no .env file -- the compose file carries defaults. +NODE_ENV=development +PORT=3000 +DATABASE_URL=postgresql://blog:blog_secret@localhost:5432/blog_db +LOG_LEVEL=debug +APP_VERSION=0.0.1 +HEALTH_DB_TIMEOUT_MS=2000 +# Required in production (min 32 chars). Generate with: openssl rand -hex 32 +JWT_SECRET=dev-secret-change-me-in-production-min-32-chars diff --git a/examples/blog-api-node/api/.gitignore b/examples/blog-api-node/api/.gitignore new file mode 100644 index 0000000..525b319 --- /dev/null +++ b/examples/blog-api-node/api/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +.env +.env.local +.env.*.local +*.log +.wrangler/ +.dev.vars +.aws-sam/ +coverage/ +.DS_Store diff --git a/examples/blog-api-node/api/Dockerfile b/examples/blog-api-node/api/Dockerfile new file mode 100644 index 0000000..fe3bd11 --- /dev/null +++ b/examples/blog-api-node/api/Dockerfile @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1 + +# ---- Build stage ---- +FROM node:22-alpine AS build + +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /app + +# Copy dependency manifests first for better layer caching +COPY package.json pnpm-lock.yaml ./ + +RUN pnpm install --frozen-lockfile + +# Copy source code +COPY tsconfig.json tsconfig.base.json drizzle.config.ts ./ +COPY src/ ./src/ + +# Build the application +RUN pnpm run build + +# Prune dev dependencies +RUN pnpm prune --prod + +# ---- Production stage ---- +FROM node:22-alpine AS production + +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Security: run as non-root user +RUN addgroup --system --gid 1001 appgroup && adduser --system --uid 1001 --ingroup appgroup appuser + +WORKDIR /app + +# Copy built artifacts and production dependencies +COPY --from=build --chown=appuser:appgroup /app/dist ./dist +COPY --from=build --chown=appuser:appgroup /app/node_modules ./node_modules +COPY --from=build --chown=appuser:appgroup /app/package.json ./ +COPY --from=build --chown=appuser:appgroup /app/drizzle.config.ts ./ + +# Copy migrations if they exist +COPY --from=build --chown=appuser:appgroup /app/src/db/migrations ./src/db/migrations + +USER appuser + +EXPOSE 3000 + +ENV NODE_ENV=production +ENV PORT=3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 + +CMD ["node", "dist/index.js"] diff --git a/examples/blog-api-node/api/docker-compose.yml b/examples/blog-api-node/api/docker-compose.yml new file mode 100644 index 0000000..6666c6c --- /dev/null +++ b/examples/blog-api-node/api/docker-compose.yml @@ -0,0 +1,81 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + image: blog-api-node:local + container_name: blog-api + restart: unless-stopped + ports: + - '${PORT:-3000}:3000' + environment: + NODE_ENV: ${NODE_ENV:-development} + DATABASE_URL: postgresql://${POSTGRES_USER:-blog}:${POSTGRES_PASSWORD:-blog_secret}@postgres:5432/${POSTGRES_DB:-blog_db} + PORT: 3000 + LOG_LEVEL: ${LOG_LEVEL:-debug} + # Required by the API (min 32 chars). Override in production: + # JWT_SECRET=$(openssl rand -hex 32) docker compose up -d + JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me-in-production-min-32-chars} + depends_on: + postgres: + condition: service_healthy + migrate: + condition: service_completed_successfully + networks: + - blog-network + healthcheck: + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3000/health'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + # One-shot job that applies the committed Drizzle migrations before the API + # starts. It runs the compiled migration runner (src/db/migrate.ts) inside + # the same production image as the API — drizzle-orm ships the migrator as + # a runtime dependency, so no dev tooling is needed in the container. + # Seed the database with: docker compose run --rm migrate node dist/db/seed.js + migrate: + build: + context: . + dockerfile: Dockerfile + image: blog-api-node:local + container_name: blog-api-migrate + command: ['node', 'dist/db/migrate.js'] + restart: 'no' + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-blog}:${POSTGRES_PASSWORD:-blog_secret}@postgres:5432/${POSTGRES_DB:-blog_db} + depends_on: + postgres: + condition: service_healthy + networks: + - blog-network + + postgres: + image: postgres:17-alpine + container_name: blog-postgres + restart: unless-stopped + ports: + - '${DB_PORT:-5432}:5432' + environment: + POSTGRES_USER: ${POSTGRES_USER:-blog} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-blog_secret} + POSTGRES_DB: ${POSTGRES_DB:-blog_db} + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - blog-network + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-blog} -d ${POSTGRES_DB:-blog_db}'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + postgres_data: + driver: local + +networks: + blog-network: + driver: bridge diff --git a/examples/blog-api-node/api/drizzle.config.ts b/examples/blog-api-node/api/drizzle.config.ts new file mode 100644 index 0000000..6f5fc3c --- /dev/null +++ b/examples/blog-api-node/api/drizzle.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './src/db/migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, + verbose: true, + strict: true, +}); diff --git a/examples/blog-api-node/api/eslint.config.js b/examples/blog-api-node/api/eslint.config.js new file mode 100644 index 0000000..5f4b2c9 --- /dev/null +++ b/examples/blog-api-node/api/eslint.config.js @@ -0,0 +1,53 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.strictTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-unsafe-return': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/consistent-return': 'error', + '@typescript-eslint/explicit-function-return-type': [ + 'warn', + { + allowExpressions: true, + allowTypedFunctionExpressions: true, + }, + ], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { prefer: 'type-imports', fixStyle: 'inline-type-imports' }, + ], + '@typescript-eslint/no-misused-promises': [ + 'error', + { checksVoidReturn: { attributes: false } }, + ], + 'no-console': ['warn', { allow: ['warn', 'error'] }], + 'prefer-const': 'error', + eqeqeq: ['error', 'always'], + }, + }, + { + ignores: ['dist/', 'node_modules/', '*.config.*', 'drizzle/'], + }, +); diff --git a/examples/blog-api-node/api/package.json b/examples/blog-api-node/api/package.json new file mode 100644 index 0000000..c3e4366 --- /dev/null +++ b/examples/blog-api-node/api/package.json @@ -0,0 +1,45 @@ +{ + "name": "blog-api-node", + "version": "0.0.1", + "private": true, + "type": "module", + "packageManager": "pnpm@10.15.1", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "typecheck": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio", + "db:seed": "tsx src/db/seed.ts" + }, + "dependencies": { + "@hono/node-server": "^2.0.4", + "@hono/zod-validator": "^0.8.0", + "drizzle-orm": "^0.45.2", + "hono": "^4.12.25", + "jose": "^6.2.3", + "postgres": "^3.4.9", + "zod": "^4.4.3" + }, + "devDependencies": { + "@electric-sql/pglite": "^0.5.2", + "@eslint/js": "^10.0.1", + "@types/node": "^25.9.3", + "@vitest/coverage-v8": "^4.1.8", + "drizzle-kit": "^0.31.10", + "eslint": "^10.5.0", + "prettier": "^3.8.4", + "tsx": "^4.22.4", + "typescript": "^6.0.3", + "typescript-eslint": "^8.61.0", + "vitest": "^4.1.8" + } +} diff --git a/examples/blog-api-node/api/pnpm-lock.yaml b/examples/blog-api-node/api/pnpm-lock.yaml new file mode 100644 index 0000000..1664df9 --- /dev/null +++ b/examples/blog-api-node/api/pnpm-lock.yaml @@ -0,0 +1,2706 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@hono/node-server': + specifier: ^2.0.4 + version: 2.0.4(hono@4.12.25) + '@hono/zod-validator': + specifier: ^0.8.0 + version: 0.8.0(hono@4.12.25)(zod@4.4.3) + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@electric-sql/pglite@0.5.2)(postgres@3.4.9) + hono: + specifier: ^4.12.25 + version: 4.12.25 + jose: + specifier: ^6.2.3 + version: 6.2.3 + postgres: + specifier: ^3.4.9 + version: 3.4.9 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@electric-sql/pglite': + specifier: ^0.5.2 + version: 0.5.2 + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.5.0) + '@types/node': + specifier: ^25.9.3 + version: 25.9.3 + '@vitest/coverage-v8': + specifier: ^4.1.8 + version: 4.1.8(vitest@4.1.8) + drizzle-kit: + specifier: ^0.31.10 + version: 0.31.10 + eslint: + specifier: ^10.5.0 + version: 10.5.0 + prettier: + specifier: ^3.8.4 + version: 3.8.4 + tsx: + specifier: ^4.22.4 + version: 4.22.4 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + typescript-eslint: + specifier: ^8.61.0 + version: 8.61.0(eslint@10.5.0)(typescript@6.0.3) + vitest: + specifier: ^4.1.8 + version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(tsx@4.22.4)) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@electric-sql/pglite@0.5.2': + resolution: {integrity: sha512-y6ySdFE7FQB7BkVaSNaPINgY7mXCyvi+3GQB5ySX9WGwgrdh31WXMP5RM40oAyYff47lIr7xdcW8UbCW5NHDmw==} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@hono/node-server@2.0.4': + resolution: {integrity: sha512-Ut3y0dMMPWy6bZ2kVfx25EOVbZlm15dhF4mOsezMlhpNHy+4MkU1qN9Y6lnruYi4wPmFzimGX2X7LF/FwHli4A==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + + '@hono/zod-validator@0.8.0': + resolution: {integrity: sha512-5uS4S1/LKtZQYvD4BtpPUFkOv8d1wNxHHrChm26buMiEYc1FrHWvDUaKVBwkiVtvSExHSpLGDvcnpI2Copyj9w==} + peerDependencies: + hono: '>=4.10.0' + zod: ^3.25.0 || ^4.0.0 + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@25.9.3': + resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + + '@typescript-eslint/eslint-plugin@8.61.0': + resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.61.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.61.0': + resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.61.0': + resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.61.0': + resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.61.0': + resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.61.0': + resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.61.0': + resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.61.0': + resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.61.0': + resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.61.0': + resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/coverage-v8@4.1.8': + resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} + peerDependencies: + '@vitest/browser': 4.1.8 + vitest: 4.1.8 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.5.0: + resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hono@4.12.25: + resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + engines: {node: '>=16.9.0'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.4: + resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.61.0: + resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@drizzle-team/brocli@0.10.2': {} + + '@electric-sql/pglite@0.5.2': {} + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0)': + dependencies: + eslint: 10.5.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.5.0)': + optionalDependencies: + eslint: 10.5.0 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@hono/node-server@2.0.4(hono@4.12.25)': + dependencies: + hono: 4.12.25 + + '@hono/zod-validator@0.8.0(hono@4.12.25)(zod@4.4.3)': + dependencies: + hono: 4.12.25 + zod: 4.4.3 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.133.0': {} + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@25.9.3': + dependencies: + undici-types: 7.24.6 + + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.61.0 + eslint: 10.5.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.61.0(eslint@10.5.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.61.0 + debug: 4.4.3 + eslint: 10.5.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.61.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.61.0': + dependencies: + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 + + '@typescript-eslint/tsconfig-utils@8.61.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.61.0(eslint@10.5.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0)(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.5.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.61.0': {} + + '@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.61.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.4 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.61.0(eslint@10.5.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + eslint: 10.5.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.61.0': + dependencies: + '@typescript-eslint/types': 8.61.0 + eslint-visitor-keys: 5.0.1 + + '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.8 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(tsx@4.22.4)) + + '@vitest/expect@4.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(tsx@4.22.4))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(tsx@4.22.4) + + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.8': + dependencies: + '@vitest/utils': 4.1.8 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.8': {} + + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + balanced-match@4.0.4: {} + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + buffer-from@1.1.2: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + detect-libc@2.1.2: {} + + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.22.4 + + drizzle-orm@0.45.2(@electric-sql/pglite@0.5.2)(postgres@3.4.9): + optionalDependencies: + '@electric-sql/pglite': 0.5.2 + postgres: 3.4.9 + + es-module-lexer@2.1.0: {} + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-string-regexp@4.0.0: {} + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.5.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + has-flag@4.0.0: {} + + hono@4.12.25: {} + + html-escaper@2.0.2: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jose@6.2.3: {} + + js-tokens@10.0.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.4 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + obug@2.1.3: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres@3.4.9: {} + + prelude-ls@1.2.1: {} + + prettier@3.8.4: {} + + punycode@2.3.1: {} + + resolve-pkg-maps@1.0.0: {} + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + semver@7.8.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@2.8.1: + optional: true + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.61.0(eslint@10.5.0)(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0)(typescript@6.0.3) + eslint: 10.5.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(tsx@4.22.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.3 + esbuild: 0.28.1 + fsevents: 2.3.3 + tsx: 4.22.4 + + vitest@4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(tsx@4.22.4)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(tsx@4.22.4)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(tsx@4.22.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.3 + '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + transitivePeerDependencies: + - msw + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} + + zod@4.4.3: {} diff --git a/examples/blog-api-node/api/prettier.config.js b/examples/blog-api-node/api/prettier.config.js new file mode 100644 index 0000000..edfd95e --- /dev/null +++ b/examples/blog-api-node/api/prettier.config.js @@ -0,0 +1,13 @@ +/** @type {import("prettier").Config} */ +export default { + tabWidth: 2, + useTabs: false, + singleQuote: true, + trailingComma: 'all', + printWidth: 100, + semi: true, + bracketSpacing: true, + arrowParens: 'always', + endOfLine: 'lf', + plugins: [], +}; diff --git a/examples/blog-api-node/api/src/app.ts b/examples/blog-api-node/api/src/app.ts new file mode 100644 index 0000000..783f34d --- /dev/null +++ b/examples/blog-api-node/api/src/app.ts @@ -0,0 +1,56 @@ +import { Hono } from 'hono'; +import { compress } from 'hono/compress'; +import { cors } from 'hono/cors'; +import { etag } from 'hono/etag'; +import { logger } from 'hono/logger'; +import { requestId } from 'hono/request-id'; +import { secureHeaders } from 'hono/secure-headers'; +import { config } from './config.js'; +import type { Database } from './db/client.js'; +import { apiError } from './lib/errors.js'; +import { authRoutes } from './routes/auth.js'; +import { commentsRoutes, postCommentsRoutes } from './routes/comments.js'; +import { healthRoutes } from './routes/health.js'; +import { postsRoutes } from './routes/posts.js'; +import { usersRoutes } from './routes/users.js'; +import type { AppEnv } from './types.js'; + +/** + * Builds the Hono app around an injected database, so production + * (src/index.ts, postgres-js pool) and tests (tests/helpers.ts, PGlite) run + * the exact same application code. + */ +export function createApp(db: Database): Hono { + const app = new Hono(); + + app.use('*', async (c, next) => { + c.set('db', db); + await next(); + }); + + if (config.NODE_ENV !== 'test') { + app.use('*', logger()); + } + app.use('*', cors()); + app.use('*', compress()); + app.use('*', etag()); + app.use('*', secureHeaders()); + app.use('*', requestId()); + + app.route('/health', healthRoutes); + app.route('/auth', authRoutes); + app.route('/users', usersRoutes); + app.route('/posts', postsRoutes); + app.route('/posts/:postId/comments', postCommentsRoutes); + app.route('/comments', commentsRoutes); + + app.get('/', (c) => c.json({ message: 'Blog API (Nerva example)', version: config.APP_VERSION })); + + app.notFound((c) => apiError(c, 404, 'NOT_FOUND', 'Route not found')); + app.onError((err, c) => { + console.error('Unhandled error:', err); + return apiError(c, 500, 'INTERNAL_ERROR', 'An unexpected error occurred'); + }); + + return app; +} diff --git a/examples/blog-api-node/api/src/config.ts b/examples/blog-api-node/api/src/config.ts new file mode 100644 index 0000000..fe9cd48 --- /dev/null +++ b/examples/blog-api-node/api/src/config.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +const isProduction = process.env.NODE_ENV === 'production'; + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.coerce.number().int().positive().default(3000), + DATABASE_URL: z.url(), + JWT_SECRET: isProduction + ? z.string().min(32, 'JWT_SECRET must be at least 32 characters in production') + : z.string().min(32).default('dev-secret-change-me-in-production-min-32-chars'), + LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default(isProduction ? 'info' : 'debug'), + APP_VERSION: z.string().default('unknown'), + HEALTH_DB_TIMEOUT_MS: z.coerce.number().int().positive().default(2000), +}); + +export type Config = z.infer; + +export const config: Config = envSchema.parse(process.env); diff --git a/examples/blog-api-node/api/src/db/client.ts b/examples/blog-api-node/api/src/db/client.ts new file mode 100644 index 0000000..7921fd9 --- /dev/null +++ b/examples/blog-api-node/api/src/db/client.ts @@ -0,0 +1,20 @@ +import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core'; +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema.js'; + +// Driver-agnostic database type: satisfied by the postgres-js client used in +// production (src/index.ts) and by the PGlite client used in tests +// (tests/helpers.ts), so routes never know which driver they run on. +export type Database = PgDatabase; + +export interface DbClient { + client: postgres.Sql; + db: PostgresJsDatabase; +} + +export function createDbClient(databaseUrl: string): DbClient { + const client = postgres(databaseUrl); + const db = drizzle(client, { schema }); + return { client, db }; +} diff --git a/examples/blog-api-node/api/src/db/migrate.ts b/examples/blog-api-node/api/src/db/migrate.ts new file mode 100644 index 0000000..370fa5f --- /dev/null +++ b/examples/blog-api-node/api/src/db/migrate.ts @@ -0,0 +1,22 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import postgres from 'postgres'; +import * as schema from './schema.js'; + +// Programmatic migration runner for deployed environments. The compose +// `migrate` service runs `node dist/db/migrate.js` inside the production +// image, where drizzle-kit (a dev dependency) is not installed; drizzle-orm +// ships the migrator as part of the runtime package. +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is required'); +} + +const client = postgres(databaseUrl, { max: 1 }); + +try { + await migrate(drizzle(client, { schema }), { migrationsFolder: 'src/db/migrations' }); + console.log('Migrations applied.'); +} finally { + await client.end({ timeout: 5 }); +} diff --git a/examples/blog-api-node/api/src/db/migrations/0000_init.sql b/examples/blog-api-node/api/src/db/migrations/0000_init.sql new file mode 100644 index 0000000..1bddcd3 --- /dev/null +++ b/examples/blog-api-node/api/src/db/migrations/0000_init.sql @@ -0,0 +1,33 @@ +CREATE TABLE "comments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "post_id" uuid NOT NULL, + "author_id" uuid NOT NULL, + "body" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "posts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "author_id" uuid NOT NULL, + "title" text NOT NULL, + "content" 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 +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" text NOT NULL, + "name" text NOT NULL, + "password_hash" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "posts" ADD CONSTRAINT "posts_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "comments_post_id_idx" ON "comments" USING btree ("post_id");--> statement-breakpoint +CREATE INDEX "posts_author_id_idx" ON "posts" USING btree ("author_id");--> statement-breakpoint +CREATE INDEX "posts_created_at_idx" ON "posts" USING btree ("created_at"); \ No newline at end of file diff --git a/examples/blog-api-node/api/src/db/migrations/meta/0000_snapshot.json b/examples/blog-api-node/api/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..dfb5f8d --- /dev/null +++ b/examples/blog-api-node/api/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,264 @@ +{ + "id": "2fc26494-99b3-45b3-853e-bd694bdbd5e2", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_post_id_idx": { + "name": "comments_post_id_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_post_id_posts_id_fk": { + "name": "comments_post_id_posts_id_fk", + "tableFrom": "comments", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_author_id_users_id_fk": { + "name": "comments_author_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "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": { + "posts_author_id_idx": { + "name": "posts_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_created_at_idx": { + "name": "posts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_author_id_users_id_fk": { + "name": "posts_author_id_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/examples/blog-api-node/api/src/db/migrations/meta/_journal.json b/examples/blog-api-node/api/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000..b766f9c --- /dev/null +++ b/examples/blog-api-node/api/src/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1781311633809, + "tag": "0000_init", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/examples/blog-api-node/api/src/db/schema.ts b/examples/blog-api-node/api/src/db/schema.ts new file mode 100644 index 0000000..cf0a75a --- /dev/null +++ b/examples/blog-api-node/api/src/db/schema.ts @@ -0,0 +1,68 @@ +import { relations } from 'drizzle-orm'; +import { index, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: uuid('id').defaultRandom().primaryKey(), + email: text('email').notNull().unique(), + name: text('name').notNull(), + passwordHash: text('password_hash').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export const posts = pgTable( + 'posts', + { + id: uuid('id').defaultRandom().primaryKey(), + authorId: uuid('author_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + content: text('content').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('posts_author_id_idx').on(table.authorId), + index('posts_created_at_idx').on(table.createdAt), + ], +); + +export const comments = pgTable( + 'comments', + { + id: uuid('id').defaultRandom().primaryKey(), + postId: uuid('post_id') + .notNull() + .references(() => posts.id, { onDelete: 'cascade' }), + authorId: uuid('author_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + body: text('body').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [index('comments_post_id_idx').on(table.postId)], +); + +export const usersRelations = relations(users, ({ many }) => ({ + posts: many(posts), + comments: many(comments), +})); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + author: one(users, { fields: [posts.authorId], references: [users.id] }), + comments: many(comments), +})); + +export const commentsRelations = relations(comments, ({ one }) => ({ + post: one(posts, { fields: [comments.postId], references: [posts.id] }), + author: one(users, { fields: [comments.authorId], references: [users.id] }), +})); + +export type User = typeof users.$inferSelect; +export type Post = typeof posts.$inferSelect; +export type Comment = typeof comments.$inferSelect; + +// Column allowlist for embedding a user in public responses (never the email +// or password hash). +export const publicUserColumns = { id: true, name: true } as const; diff --git a/examples/blog-api-node/api/src/db/seed.ts b/examples/blog-api-node/api/src/db/seed.ts new file mode 100644 index 0000000..8aa0ac1 --- /dev/null +++ b/examples/blog-api-node/api/src/db/seed.ts @@ -0,0 +1,81 @@ +import { hashPassword } from '../lib/password.js'; +import { createDbClient } from './client.js'; +import { comments, posts, users } from './schema.js'; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is required'); +} + +const { client, db } = createDbClient(databaseUrl); + +async function seed(): Promise { + console.log('Seeding database...'); + + // Wipe in foreign-key order so the seed is repeatable. + await db.delete(comments); + await db.delete(posts); + await db.delete(users); + + const passwordHash = await hashPassword('password123'); + const [alice, bob] = await db + .insert(users) + .values([ + { email: 'alice@example.com', name: 'Alice Author', passwordHash }, + { email: 'bob@example.com', name: 'Bob Blogger', passwordHash }, + ]) + .returning(); + if (!alice || !bob) { + throw new Error('Failed to seed users'); + } + + const seededPosts = await db + .insert(posts) + .values([ + { + authorId: alice.id, + title: 'Hello, Nerva', + content: + 'This blog API was generated with `setup-project.sh --node` and extended with auth, relations, and pagination. Browse the source to see how the pieces fit together.', + }, + { + authorId: alice.id, + title: 'Schema-first thinking', + content: + 'Drizzle schemas are the single source of truth here: migrations, query types, and seed data all derive from src/db/schema.ts.', + }, + { + authorId: bob.id, + title: 'Shipping with Docker Compose', + content: + 'docker compose up builds the API image, starts PostgreSQL, runs migrations, and brings the API up behind a health check.', + }, + ]) + .returning(); + const [firstPost] = seededPosts; + if (!firstPost) { + throw new Error('Failed to seed posts'); + } + + await db.insert(comments).values([ + { + postId: firstPost.id, + authorId: bob.id, + body: 'Great introduction — the auth flow tests are worth a read too.', + }, + { + postId: firstPost.id, + authorId: alice.id, + body: 'Thanks Bob! The refresh-token round trip is in tests/integration/auth.test.ts.', + }, + ]); + + console.log('Database seeded: 2 users, 3 posts, 2 comments.'); + console.log('Log in with alice@example.com or bob@example.com and password "password123".'); + await client.end(); +} + +seed().catch((err: unknown) => { + console.error('Seed failed:', err); + process.exit(1); +}); diff --git a/examples/blog-api-node/api/src/index.ts b/examples/blog-api-node/api/src/index.ts new file mode 100644 index 0000000..2aeead5 --- /dev/null +++ b/examples/blog-api-node/api/src/index.ts @@ -0,0 +1,51 @@ +import { serve } from '@hono/node-server'; +import { createApp } from './app.js'; +import { config } from './config.js'; +import { createDbClient } from './db/client.js'; + +const { client, db } = createDbClient(config.DATABASE_URL); +const app = createApp(db); + +console.log(`Server starting on port ${String(config.PORT)}`); + +const server = serve({ fetch: app.fetch, port: config.PORT }); + +// --- Graceful shutdown --- +const SHUTDOWN_TIMEOUT_MS = 10_000; +let isShuttingDown = false; + +function shutdown(signal: string): void { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + + console.log(`\n${signal} received. Shutting down gracefully...`); + + const forceExit = setTimeout(() => { + console.error(`Forced shutdown after ${String(SHUTDOWN_TIMEOUT_MS / 1000)}s timeout.`); + process.exit(1); + }, SHUTDOWN_TIMEOUT_MS); + forceExit.unref(); + + server.close(() => { + console.log('HTTP server closed.'); + client + .end({ timeout: 5 }) + .then(() => { + console.log('Database pool closed. Shutdown complete.'); + process.exit(0); + }) + .catch((err: unknown) => { + console.error('Error closing database pool:', err); + process.exit(1); + }); + }); +} + +process.on('SIGTERM', () => { + shutdown('SIGTERM'); +}); +process.on('SIGINT', () => { + shutdown('SIGINT'); +}); diff --git a/examples/blog-api-node/api/src/lib/errors.ts b/examples/blog-api-node/api/src/lib/errors.ts new file mode 100644 index 0000000..2bc203a --- /dev/null +++ b/examples/blog-api-node/api/src/lib/errors.ts @@ -0,0 +1,26 @@ +import type { Context } from 'hono'; +import type { ContentfulStatusCode } from 'hono/utils/http-status'; + +// Error codes from the Nerva API standards (docs/api-development/README.md). +export type ErrorCode = + | 'VALIDATION_ERROR' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'CONFLICT' + | 'INTERNAL_ERROR'; + +export interface ErrorDetail { + field: string; + message: string; +} + +export function apiError( + c: Context, + status: ContentfulStatusCode, + code: ErrorCode, + message: string, + details?: ErrorDetail[], +): Response { + return c.json({ error: { code, message, ...(details ? { details } : {}) } }, status); +} diff --git a/examples/blog-api-node/api/src/lib/pagination.ts b/examples/blog-api-node/api/src/lib/pagination.ts new file mode 100644 index 0000000..8bdf02d --- /dev/null +++ b/examples/blog-api-node/api/src/lib/pagination.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +// Offset-based pagination: ?limit=20&offset=40. The schema coerces query +// strings to numbers and clamps the page size. +export const paginationQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(20), + offset: z.coerce.number().int().min(0).default(0), +}); + +export type Pagination = z.infer; + +export interface PageMeta { + total: number; + limit: number; + offset: number; +} + +export function pageMeta(total: number, { limit, offset }: Pagination): PageMeta { + return { total, limit, offset }; +} diff --git a/examples/blog-api-node/api/src/lib/password.ts b/examples/blog-api-node/api/src/lib/password.ts new file mode 100644 index 0000000..f7b4f72 --- /dev/null +++ b/examples/blog-api-node/api/src/lib/password.ts @@ -0,0 +1,33 @@ +import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'; + +// scrypt comes with Node -- no native dependency to compile in the Docker +// image. Hashes are stored as `:`. +const KEY_LENGTH = 64; + +function deriveKey(password: string, salt: Buffer): Promise { + return new Promise((resolve, reject) => { + scrypt(password, salt, KEY_LENGTH, (err, key) => { + if (err) { + reject(err); + return; + } + resolve(key); + }); + }); +} + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16); + const key = await deriveKey(password, salt); + return `${salt.toString('hex')}:${key.toString('hex')}`; +} + +export async function verifyPassword(password: string, storedHash: string): Promise { + const [saltHex, keyHex] = storedHash.split(':'); + if (!saltHex || !keyHex) { + return false; + } + const expected = Buffer.from(keyHex, 'hex'); + const actual = await deriveKey(password, Buffer.from(saltHex, 'hex')); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} diff --git a/examples/blog-api-node/api/src/lib/tokens.ts b/examples/blog-api-node/api/src/lib/tokens.ts new file mode 100644 index 0000000..a77092c --- /dev/null +++ b/examples/blog-api-node/api/src/lib/tokens.ts @@ -0,0 +1,54 @@ +import { jwtVerify, SignJWT } from 'jose'; +import { config } from '../config.js'; + +const JWT_ISSUER = 'blog-api-node'; +const JWT_AUDIENCE = 'blog-api-node:client'; +const ACCESS_TOKEN_TTL = '15m'; +const REFRESH_TOKEN_TTL = '7d'; + +const secret = new TextEncoder().encode(config.JWT_SECRET); + +export type TokenType = 'access' | 'refresh'; + +export interface TokenPair { + accessToken: string; + refreshToken: string; +} + +async function signToken(userId: string, type: TokenType, ttl: string): Promise { + return new SignJWT({ type }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(userId) + .setIssuer(JWT_ISSUER) + .setAudience(JWT_AUDIENCE) + .setIssuedAt() + .setExpirationTime(ttl) + .sign(secret); +} + +export async function issueTokenPair(userId: string): Promise { + return { + accessToken: await signToken(userId, 'access', ACCESS_TOKEN_TTL), + refreshToken: await signToken(userId, 'refresh', REFRESH_TOKEN_TTL), + }; +} + +/** + * Returns the user id (`sub`) when the token is valid, unexpired, and of the + * expected type; otherwise null. The type claim prevents a refresh token from + * being replayed as an access token (and vice versa). + */ +export async function verifyToken(token: string, expectedType: TokenType): Promise { + try { + const { payload } = await jwtVerify(token, secret, { + issuer: JWT_ISSUER, + audience: JWT_AUDIENCE, + }); + if (payload.type !== expectedType || typeof payload.sub !== 'string') { + return null; + } + return payload.sub; + } catch { + return null; + } +} diff --git a/examples/blog-api-node/api/src/lib/validate.ts b/examples/blog-api-node/api/src/lib/validate.ts new file mode 100644 index 0000000..d950bd7 --- /dev/null +++ b/examples/blog-api-node/api/src/lib/validate.ts @@ -0,0 +1,31 @@ +import { zValidator } from '@hono/zod-validator'; +import type { ValidationTargets } from 'hono'; +import type { z } from 'zod'; + +/** + * zValidator with the standard Nerva error envelope on failure. Handlers + * behind it read the parsed value with `c.req.valid(target)`. + */ +export function validate( + target: Target, + schema: Schema, +) { + return zValidator(target, schema, (result, c) => { + if (!result.success) { + return c.json( + { + error: { + code: 'VALIDATION_ERROR', + message: 'Request validation failed', + details: result.error.issues.map((issue) => ({ + field: issue.path.map(String).join('.') || target, + message: issue.message, + })), + }, + }, + 400, + ); + } + return undefined; + }); +} diff --git a/examples/blog-api-node/api/src/middleware/auth.ts b/examples/blog-api-node/api/src/middleware/auth.ts new file mode 100644 index 0000000..0aa1536 --- /dev/null +++ b/examples/blog-api-node/api/src/middleware/auth.ts @@ -0,0 +1,35 @@ +import { eq } from 'drizzle-orm'; +import { createMiddleware } from 'hono/factory'; +import { users } from '../db/schema.js'; +import { apiError } from '../lib/errors.js'; +import { verifyToken } from '../lib/tokens.js'; +import type { AppEnv } from '../types.js'; + +const BEARER_PREFIX = 'Bearer '; + +/** + * Requires a valid access token. Loads the user from the database (a token + * for a deleted account is rejected) and exposes it as `c.var.user`. + */ +export const requireAuth = createMiddleware(async (c, next) => { + const authorization = c.req.header('Authorization'); + if (!authorization?.startsWith(BEARER_PREFIX)) { + return apiError(c, 401, 'UNAUTHORIZED', 'Missing bearer token'); + } + + const userId = await verifyToken(authorization.slice(BEARER_PREFIX.length), 'access'); + if (!userId) { + return apiError(c, 401, 'UNAUTHORIZED', 'Invalid or expired access token'); + } + + const [user] = await c.var.db + .select({ id: users.id, email: users.email, name: users.name }) + .from(users) + .where(eq(users.id, userId)); + if (!user) { + return apiError(c, 401, 'UNAUTHORIZED', 'User no longer exists'); + } + + c.set('user', user); + return next(); +}); diff --git a/examples/blog-api-node/api/src/routes/auth.ts b/examples/blog-api-node/api/src/routes/auth.ts new file mode 100644 index 0000000..bf6bfb9 --- /dev/null +++ b/examples/blog-api-node/api/src/routes/auth.ts @@ -0,0 +1,106 @@ +import { eq } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { z } from 'zod'; +import { users } from '../db/schema.js'; +import { apiError } from '../lib/errors.js'; +import { hashPassword, verifyPassword } from '../lib/password.js'; +import { issueTokenPair, verifyToken } from '../lib/tokens.js'; +import { validate } from '../lib/validate.js'; +import { requireAuth } from '../middleware/auth.js'; +import type { AppEnv } from '../types.js'; + +const registerSchema = z.object({ + email: z.email(), + name: z.string().min(1).max(120), + password: z.string().min(8).max(128), +}); + +const loginSchema = z.object({ + email: z.email(), + password: z.string().min(1), +}); + +const refreshSchema = z.object({ + refreshToken: z.string().min(1), +}); + +// What auth endpoints return about the caller's own account (email included, +// password hash never). +const ownUserSelection = { + id: users.id, + email: users.email, + name: users.name, + createdAt: users.createdAt, +}; + +export const authRoutes = new Hono() + // POST /auth/register — create an account and sign in. + .post('/register', validate('json', registerSchema), async (c) => { + const { email, name, password } = c.req.valid('json'); + const db = c.var.db; + + const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email)); + if (existing.length > 0) { + return apiError(c, 409, 'CONFLICT', 'An account with this email already exists'); + } + + const passwordHash = await hashPassword(password); + const [user] = await db + .insert(users) + .values({ email, name, passwordHash }) + .returning(ownUserSelection); + if (!user) { + return apiError(c, 500, 'INTERNAL_ERROR', 'Failed to create user'); + } + + const tokens = await issueTokenPair(user.id); + return c.json({ data: { user, ...tokens } }, 201); + }) + + // POST /auth/login — exchange credentials for a token pair. + .post('/login', validate('json', loginSchema), async (c) => { + const { email, password } = c.req.valid('json'); + const [user] = await c.var.db + .select({ ...ownUserSelection, passwordHash: users.passwordHash }) + .from(users) + .where(eq(users.email, email)); + + // Same response for unknown email and wrong password. + const valid = user ? await verifyPassword(password, user.passwordHash) : false; + if (!user || !valid) { + return apiError(c, 401, 'UNAUTHORIZED', 'Invalid email or password'); + } + + const tokens = await issueTokenPair(user.id); + const { passwordHash: _passwordHash, ...publicUser } = user; + return c.json({ data: { user: publicUser, ...tokens } }); + }) + + // POST /auth/refresh — exchange a refresh token for a fresh token pair. + .post('/refresh', validate('json', refreshSchema), async (c) => { + const { refreshToken } = c.req.valid('json'); + const userId = await verifyToken(refreshToken, 'refresh'); + if (!userId) { + return apiError(c, 401, 'UNAUTHORIZED', 'Invalid or expired refresh token'); + } + + const [user] = await c.var.db.select({ id: users.id }).from(users).where(eq(users.id, userId)); + if (!user) { + return apiError(c, 401, 'UNAUTHORIZED', 'User no longer exists'); + } + + const tokens = await issueTokenPair(userId); + return c.json({ data: tokens }); + }) + + // GET /auth/me — the authenticated caller's own profile. + .get('/me', requireAuth, async (c) => { + const [user] = await c.var.db + .select(ownUserSelection) + .from(users) + .where(eq(users.id, c.var.user.id)); + if (!user) { + return apiError(c, 404, 'NOT_FOUND', 'User not found'); + } + return c.json({ data: user }); + }); diff --git a/examples/blog-api-node/api/src/routes/comments.ts b/examples/blog-api-node/api/src/routes/comments.ts new file mode 100644 index 0000000..efcb597 --- /dev/null +++ b/examples/blog-api-node/api/src/routes/comments.ts @@ -0,0 +1,97 @@ +import { asc, count, eq } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { z } from 'zod'; +import { comments, posts, publicUserColumns } from '../db/schema.js'; +import { apiError } from '../lib/errors.js'; +import { pageMeta, paginationQuerySchema } from '../lib/pagination.js'; +import { validate } from '../lib/validate.js'; +import { requireAuth } from '../middleware/auth.js'; +import type { AppEnv } from '../types.js'; + +const postIdParamSchema = z.object({ postId: z.uuid() }); +const idParamSchema = z.object({ id: z.uuid() }); + +const createCommentSchema = z.object({ + body: z.string().min(1).max(5_000), +}); + +// Mounted at /posts/:postId/comments. +export const postCommentsRoutes = new Hono() + // GET /posts/:postId/comments — comments on a post, oldest first, paginated. + .get( + '/', + validate('param', postIdParamSchema), + validate('query', paginationQuerySchema), + async (c) => { + const { postId } = c.req.valid('param'); + const pagination = c.req.valid('query'); + const db = c.var.db; + + const [post] = await db.select({ id: posts.id }).from(posts).where(eq(posts.id, postId)); + if (!post) { + return apiError(c, 404, 'NOT_FOUND', 'Post not found'); + } + + const [data, [totalRow]] = await Promise.all([ + db.query.comments.findMany({ + where: eq(comments.postId, postId), + with: { author: { columns: publicUserColumns } }, + orderBy: [asc(comments.createdAt)], + limit: pagination.limit, + offset: pagination.offset, + }), + db.select({ total: count() }).from(comments).where(eq(comments.postId, postId)), + ]); + + return c.json({ data, meta: pageMeta(totalRow?.total ?? 0, pagination) }); + }, + ) + + // POST /posts/:postId/comments — comment as the authenticated user. + .post( + '/', + requireAuth, + validate('param', postIdParamSchema), + validate('json', createCommentSchema), + async (c) => { + const { postId } = c.req.valid('param'); + const { body } = c.req.valid('json'); + const db = c.var.db; + + const [post] = await db.select({ id: posts.id }).from(posts).where(eq(posts.id, postId)); + if (!post) { + return apiError(c, 404, 'NOT_FOUND', 'Post not found'); + } + + const [comment] = await db + .insert(comments) + .values({ postId, authorId: c.var.user.id, body }) + .returning(); + if (!comment) { + return apiError(c, 500, 'INTERNAL_ERROR', 'Failed to create comment'); + } + return c.json({ data: comment }, 201); + }, + ); + +// Mounted at /comments. +export const commentsRoutes = new Hono() + // DELETE /comments/:id — comment author only. + .delete('/:id', requireAuth, validate('param', idParamSchema), async (c) => { + const { id } = c.req.valid('param'); + const db = c.var.db; + + const [existing] = await db + .select({ authorId: comments.authorId }) + .from(comments) + .where(eq(comments.id, id)); + if (!existing) { + return apiError(c, 404, 'NOT_FOUND', 'Comment not found'); + } + if (existing.authorId !== c.var.user.id) { + return apiError(c, 403, 'FORBIDDEN', 'Only the author can delete this comment'); + } + + await db.delete(comments).where(eq(comments.id, id)); + return c.body(null, 204); + }); diff --git a/examples/blog-api-node/api/src/routes/health.ts b/examples/blog-api-node/api/src/routes/health.ts new file mode 100644 index 0000000..c65810c --- /dev/null +++ b/examples/blog-api-node/api/src/routes/health.ts @@ -0,0 +1,37 @@ +import { sql } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { config } from '../config.js'; +import type { AppEnv } from '../types.js'; + +const startTime = Date.now(); + +export const healthRoutes = new Hono().get('/', async (c) => { + const timeout = new Promise<'disconnected'>((resolve) => { + setTimeout(() => { + resolve('disconnected'); + }, config.HEALTH_DB_TIMEOUT_MS); + }); + + const ping = async (): Promise<'connected'> => { + await c.var.db.execute(sql`SELECT 1`); + return 'connected'; + }; + + let database: 'connected' | 'disconnected'; + try { + database = await Promise.race([ping(), timeout]); + } catch { + database = 'disconnected'; + } + + const status = database === 'connected' ? 'healthy' : 'unhealthy'; + const body = { + status, + version: config.APP_VERSION, + uptime: Math.floor((Date.now() - startTime) / 1000), + timestamp: new Date().toISOString(), + requestId: c.get('requestId'), + checks: { database }, + }; + return c.json(body, status === 'healthy' ? 200 : 503); +}); diff --git a/examples/blog-api-node/api/src/routes/posts.ts b/examples/blog-api-node/api/src/routes/posts.ts new file mode 100644 index 0000000..7ea7c86 --- /dev/null +++ b/examples/blog-api-node/api/src/routes/posts.ts @@ -0,0 +1,132 @@ +import { count, desc, eq } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { z } from 'zod'; +import { posts, publicUserColumns } from '../db/schema.js'; +import { apiError } from '../lib/errors.js'; +import { pageMeta, paginationQuerySchema } from '../lib/pagination.js'; +import { validate } from '../lib/validate.js'; +import { requireAuth } from '../middleware/auth.js'; +import type { AppEnv } from '../types.js'; + +const idParamSchema = z.object({ id: z.uuid() }); + +const createPostSchema = z.object({ + title: z.string().min(1).max(200), + content: z.string().min(1).max(50_000), +}); + +const updatePostSchema = z + .object({ + title: z.string().min(1).max(200).optional(), + content: z.string().min(1).max(50_000).optional(), + }) + .refine((body) => body.title !== undefined || body.content !== undefined, { + message: 'At least one of title or content is required', + }); + +export const postsRoutes = new Hono() + // GET /posts — all posts with their author, newest first, paginated. + .get('/', validate('query', paginationQuerySchema), async (c) => { + const pagination = c.req.valid('query'); + const db = c.var.db; + + const [data, [totalRow]] = await Promise.all([ + db.query.posts.findMany({ + with: { author: { columns: publicUserColumns } }, + orderBy: [desc(posts.createdAt)], + limit: pagination.limit, + offset: pagination.offset, + }), + db.select({ total: count() }).from(posts), + ]); + + return c.json({ data, meta: pageMeta(totalRow?.total ?? 0, pagination) }); + }) + + // GET /posts/:id — a single post with its author. + .get('/:id', validate('param', idParamSchema), async (c) => { + const { id } = c.req.valid('param'); + const post = await c.var.db.query.posts.findFirst({ + where: eq(posts.id, id), + with: { author: { columns: publicUserColumns } }, + }); + if (!post) { + return apiError(c, 404, 'NOT_FOUND', 'Post not found'); + } + return c.json({ data: post }); + }) + + // POST /posts — create a post as the authenticated user. + .post('/', requireAuth, validate('json', createPostSchema), async (c) => { + const { title, content } = c.req.valid('json'); + const [post] = await c.var.db + .insert(posts) + .values({ title, content, authorId: c.var.user.id }) + .returning(); + if (!post) { + return apiError(c, 500, 'INTERNAL_ERROR', 'Failed to create post'); + } + return c.json({ data: post }, 201); + }) + + // PATCH /posts/:id — partial update, author only. + .patch( + '/:id', + requireAuth, + validate('param', idParamSchema), + validate('json', updatePostSchema), + async (c) => { + const { id } = c.req.valid('param'); + const body = c.req.valid('json'); + const db = c.var.db; + + const [existing] = await db + .select({ authorId: posts.authorId }) + .from(posts) + .where(eq(posts.id, id)); + if (!existing) { + return apiError(c, 404, 'NOT_FOUND', 'Post not found'); + } + if (existing.authorId !== c.var.user.id) { + return apiError(c, 403, 'FORBIDDEN', 'Only the author can edit this post'); + } + + const updates: { title?: string; content?: string } = {}; + if (body.title !== undefined) { + updates.title = body.title; + } + if (body.content !== undefined) { + updates.content = body.content; + } + + const [updated] = await db + .update(posts) + .set({ ...updates, updatedAt: new Date() }) + .where(eq(posts.id, id)) + .returning(); + if (!updated) { + return apiError(c, 500, 'INTERNAL_ERROR', 'Failed to update post'); + } + return c.json({ data: updated }); + }, + ) + + // DELETE /posts/:id — author only; comments cascade in the database. + .delete('/:id', requireAuth, validate('param', idParamSchema), async (c) => { + const { id } = c.req.valid('param'); + const db = c.var.db; + + const [existing] = await db + .select({ authorId: posts.authorId }) + .from(posts) + .where(eq(posts.id, id)); + if (!existing) { + return apiError(c, 404, 'NOT_FOUND', 'Post not found'); + } + if (existing.authorId !== c.var.user.id) { + return apiError(c, 403, 'FORBIDDEN', 'Only the author can delete this post'); + } + + await db.delete(posts).where(eq(posts.id, id)); + return c.body(null, 204); + }); diff --git a/examples/blog-api-node/api/src/routes/users.ts b/examples/blog-api-node/api/src/routes/users.ts new file mode 100644 index 0000000..5efb46b --- /dev/null +++ b/examples/blog-api-node/api/src/routes/users.ts @@ -0,0 +1,53 @@ +import { count, desc, eq } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { z } from 'zod'; +import { posts, users } from '../db/schema.js'; +import { apiError } from '../lib/errors.js'; +import { pageMeta, paginationQuerySchema } from '../lib/pagination.js'; +import { validate } from '../lib/validate.js'; +import type { AppEnv } from '../types.js'; + +const idParamSchema = z.object({ id: z.uuid() }); + +export const usersRoutes = new Hono() + // GET /users/:id — public profile (no email, no password hash). + .get('/:id', validate('param', idParamSchema), async (c) => { + const { id } = c.req.valid('param'); + const [user] = await c.var.db + .select({ id: users.id, name: users.name, createdAt: users.createdAt }) + .from(users) + .where(eq(users.id, id)); + if (!user) { + return apiError(c, 404, 'NOT_FOUND', 'User not found'); + } + return c.json({ data: user }); + }) + + // GET /users/:id/posts — a user's posts, newest first, paginated. + .get( + '/:id/posts', + validate('param', idParamSchema), + validate('query', paginationQuerySchema), + async (c) => { + const { id } = c.req.valid('param'); + const pagination = c.req.valid('query'); + const db = c.var.db; + + const [author] = await db.select({ id: users.id }).from(users).where(eq(users.id, id)); + if (!author) { + return apiError(c, 404, 'NOT_FOUND', 'User not found'); + } + + const [data, [totalRow]] = await Promise.all([ + db.query.posts.findMany({ + where: eq(posts.authorId, id), + orderBy: [desc(posts.createdAt)], + limit: pagination.limit, + offset: pagination.offset, + }), + db.select({ total: count() }).from(posts).where(eq(posts.authorId, id)), + ]); + + return c.json({ data, meta: pageMeta(totalRow?.total ?? 0, pagination) }); + }, + ); diff --git a/examples/blog-api-node/api/src/types.ts b/examples/blog-api-node/api/src/types.ts new file mode 100644 index 0000000..c291d91 --- /dev/null +++ b/examples/blog-api-node/api/src/types.ts @@ -0,0 +1,16 @@ +import type { RequestIdVariables } from 'hono/request-id'; +import type { Database } from './db/client.js'; + +export interface AuthUser { + id: string; + email: string; + name: string; +} + +export interface AppEnv { + Variables: RequestIdVariables & { + db: Database; + // Set by the requireAuth middleware; only read in handlers behind it. + user: AuthUser; + }; +} diff --git a/examples/blog-api-node/api/tests/helpers.ts b/examples/blog-api-node/api/tests/helpers.ts new file mode 100644 index 0000000..f2c8ccf --- /dev/null +++ b/examples/blog-api-node/api/tests/helpers.ts @@ -0,0 +1,111 @@ +import { PGlite } from '@electric-sql/pglite'; +import { drizzle } from 'drizzle-orm/pglite'; +import { migrate } from 'drizzle-orm/pglite/migrator'; +import type { Hono } from 'hono'; +import type { Database } from '../src/db/client.js'; +import * as schema from '../src/db/schema.js'; +import type { AppEnv } from '../src/types.js'; + +export interface TestDb { + db: Database; + /** Empties all tables (foreign-key order). Call from beforeEach. */ + reset: () => Promise; + /** Disposes the PGlite instance. Call from afterAll. */ + cleanup: () => Promise; +} + +/** + * In-process PostgreSQL (PGlite) with the committed migrations applied — the + * same SQL that runs against the real database in docker compose. No Docker + * or external services needed to run the test suite. + */ +export async function createTestDb(): Promise { + const pglite = new PGlite(); + const db = drizzle(pglite, { schema }); + await migrate(db, { migrationsFolder: 'src/db/migrations' }); + return { + db, + reset: async () => { + await db.delete(schema.comments); + await db.delete(schema.posts); + await db.delete(schema.users); + }, + cleanup: () => pglite.close(), + }; +} + +export type TestApp = Hono; + +export function jsonHeaders(token?: string): Record { + return { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; +} + +export interface RegisteredUser { + id: string; + email: string; + name: string; + password: string; + accessToken: string; + refreshToken: string; +} + +let userCounter = 0; + +export async function registerUser( + app: TestApp, + overrides: Partial> = {}, +): Promise { + userCounter += 1; + const credentials = { + email: overrides.email ?? `user-${String(userCounter)}@example.com`, + name: overrides.name ?? `User ${String(userCounter)}`, + password: overrides.password ?? 'password123', + }; + const res = await app.request('/auth/register', { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify(credentials), + }); + if (res.status !== 201) { + throw new Error(`registerUser expected 201, got ${String(res.status)}: ${await res.text()}`); + } + const body = (await res.json()) as { + data: { user: { id: string }; accessToken: string; refreshToken: string }; + }; + return { + id: body.data.user.id, + ...credentials, + accessToken: body.data.accessToken, + refreshToken: body.data.refreshToken, + }; +} + +export interface CreatedPost { + id: string; + title: string; + content: string; + authorId: string; +} + +export async function createPost( + app: TestApp, + accessToken: string, + overrides: Partial> = {}, +): Promise { + const res = await app.request('/posts', { + method: 'POST', + headers: jsonHeaders(accessToken), + body: JSON.stringify({ + title: overrides.title ?? 'A test post', + content: overrides.content ?? 'Some test content.', + }), + }); + if (res.status !== 201) { + throw new Error(`createPost expected 201, got ${String(res.status)}: ${await res.text()}`); + } + const body = (await res.json()) as { data: CreatedPost }; + return body.data; +} diff --git a/examples/blog-api-node/api/tests/integration/auth.test.ts b/examples/blog-api-node/api/tests/integration/auth.test.ts new file mode 100644 index 0000000..b790811 --- /dev/null +++ b/examples/blog-api-node/api/tests/integration/auth.test.ts @@ -0,0 +1,206 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createApp } from '../../src/app.js'; +import { createTestDb, jsonHeaders, registerUser, type TestApp, type TestDb } from '../helpers.js'; + +interface ErrorBody { + error: { code: string; message: string; details?: { field: string; message: string }[] }; +} + +let testDb: TestDb; +let app: TestApp; + +beforeAll(async () => { + testDb = await createTestDb(); + app = createApp(testDb.db); +}); + +afterAll(async () => { + await testDb.cleanup(); +}); + +beforeEach(async () => { + await testDb.reset(); +}); + +describe('POST /auth/register', () => { + it('creates an account and returns the user with a token pair', async () => { + const res = await app.request('/auth/register', { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ email: 'ada@example.com', name: 'Ada', password: 'password123' }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as { + data: { user: Record; accessToken: string; refreshToken: string }; + }; + expect(body.data.user).toMatchObject({ email: 'ada@example.com', name: 'Ada' }); + expect(body.data.user).not.toHaveProperty('passwordHash'); + expect(body.data.accessToken).toBeTruthy(); + expect(body.data.refreshToken).toBeTruthy(); + }); + + it('rejects a duplicate email with 409 CONFLICT', async () => { + await registerUser(app, { email: 'taken@example.com' }); + const res = await app.request('/auth/register', { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ email: 'taken@example.com', name: 'Dupe', password: 'password123' }), + }); + + expect(res.status).toBe(409); + const body = (await res.json()) as ErrorBody; + expect(body.error.code).toBe('CONFLICT'); + }); + + it('rejects an invalid payload with 400 VALIDATION_ERROR and field details', async () => { + const res = await app.request('/auth/register', { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ email: 'not-an-email', name: '', password: 'short' }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as ErrorBody; + expect(body.error.code).toBe('VALIDATION_ERROR'); + const fields = (body.error.details ?? []).map((d) => d.field); + expect(fields).toEqual(expect.arrayContaining(['email', 'name', 'password'])); + }); +}); + +describe('POST /auth/login', () => { + it('returns a token pair for valid credentials', async () => { + const user = await registerUser(app); + const res = await app.request('/auth/login', { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ email: user.email, password: user.password }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { user: { id: string }; accessToken: string; refreshToken: string }; + }; + expect(body.data.user.id).toBe(user.id); + expect(body.data.accessToken).toBeTruthy(); + expect(body.data.refreshToken).toBeTruthy(); + }); + + it('rejects a wrong password with 401', async () => { + const user = await registerUser(app); + const res = await app.request('/auth/login', { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ email: user.email, password: 'wrong-password' }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorBody; + expect(body.error.code).toBe('UNAUTHORIZED'); + }); + + it('answers unknown emails exactly like wrong passwords', async () => { + const user = await registerUser(app); + const wrongPassword = await app.request('/auth/login', { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ email: user.email, password: 'wrong-password' }), + }); + const unknownEmail = await app.request('/auth/login', { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ email: 'ghost@example.com', password: 'password123' }), + }); + + expect(unknownEmail.status).toBe(401); + expect(await unknownEmail.json()).toEqual(await wrongPassword.json()); + }); +}); + +describe('POST /auth/refresh', () => { + it('exchanges a refresh token for a working new token pair', async () => { + const user = await registerUser(app); + const refreshRes = await app.request('/auth/refresh', { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ refreshToken: user.refreshToken }), + }); + + expect(refreshRes.status).toBe(200); + const body = (await refreshRes.json()) as { + data: { accessToken: string; refreshToken: string }; + }; + + // The freshly minted access token must be accepted by a protected route. + const meRes = await app.request('/auth/me', { headers: jsonHeaders(body.data.accessToken) }); + expect(meRes.status).toBe(200); + const me = (await meRes.json()) as { data: { id: string; email: string } }; + expect(me.data.id).toBe(user.id); + }); + + it('rejects an access token presented as a refresh token', async () => { + const user = await registerUser(app); + const res = await app.request('/auth/refresh', { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ refreshToken: user.accessToken }), + }); + + expect(res.status).toBe(401); + }); + + it('rejects garbage refresh tokens', async () => { + const res = await app.request('/auth/refresh', { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ refreshToken: 'not-a-token' }), + }); + + expect(res.status).toBe(401); + }); +}); + +describe('GET /auth/me', () => { + it('returns the authenticated profile including the email', async () => { + const user = await registerUser(app); + const res = await app.request('/auth/me', { headers: jsonHeaders(user.accessToken) }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { id: string; email: string; name: string } }; + expect(body.data).toMatchObject({ id: user.id, email: user.email, name: user.name }); + }); + + it('rejects requests without a token', async () => { + const res = await app.request('/auth/me'); + expect(res.status).toBe(401); + }); + + it('rejects requests with an invalid token', async () => { + const res = await app.request('/auth/me', { headers: jsonHeaders('bogus-token') }); + expect(res.status).toBe(401); + }); + + it('rejects a valid token whose user has been deleted', async () => { + const user = await registerUser(app); + await testDb.reset(); + + const res = await app.request('/auth/me', { headers: jsonHeaders(user.accessToken) }); + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorBody; + expect(body.error.code).toBe('UNAUTHORIZED'); + }); +}); + +describe('tokens for deleted accounts', () => { + it('refuses to refresh for a user that no longer exists', async () => { + const user = await registerUser(app); + await testDb.reset(); + + const res = await app.request('/auth/refresh', { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ refreshToken: user.refreshToken }), + }); + expect(res.status).toBe(401); + }); +}); diff --git a/examples/blog-api-node/api/tests/integration/comments.test.ts b/examples/blog-api-node/api/tests/integration/comments.test.ts new file mode 100644 index 0000000..114d604 --- /dev/null +++ b/examples/blog-api-node/api/tests/integration/comments.test.ts @@ -0,0 +1,190 @@ +import { count } from 'drizzle-orm'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createApp } from '../../src/app.js'; +import { comments } from '../../src/db/schema.js'; +import { + createPost, + createTestDb, + jsonHeaders, + registerUser, + type TestApp, + type TestDb, +} from '../helpers.js'; + +interface ErrorBody { + error: { code: string; message: string }; +} + +let testDb: TestDb; +let app: TestApp; + +beforeAll(async () => { + testDb = await createTestDb(); + app = createApp(testDb.db); +}); + +afterAll(async () => { + await testDb.cleanup(); +}); + +beforeEach(async () => { + await testDb.reset(); +}); + +async function addComment(token: string, postId: string, body: string): Promise { + const res = await app.request(`/posts/${postId}/comments`, { + method: 'POST', + headers: jsonHeaders(token), + body: JSON.stringify({ body }), + }); + if (res.status !== 201) { + throw new Error(`addComment expected 201, got ${String(res.status)}`); + } + const json = (await res.json()) as { data: { id: string } }; + return json.data.id; +} + +describe('POST /posts/:postId/comments', () => { + it('requires authentication', async () => { + const user = await registerUser(app); + const post = await createPost(app, user.accessToken); + + const res = await app.request(`/posts/${post.id}/comments`, { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ body: 'Anonymous?' }), + }); + expect(res.status).toBe(401); + }); + + it('creates a comment on an existing post', async () => { + const author = await registerUser(app); + const commenter = await registerUser(app); + const post = await createPost(app, author.accessToken); + + const res = await app.request(`/posts/${post.id}/comments`, { + method: 'POST', + headers: jsonHeaders(commenter.accessToken), + body: JSON.stringify({ body: 'Nice post!' }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as { + data: { postId: string; authorId: string; body: string }; + }; + expect(body.data).toMatchObject({ + postId: post.id, + authorId: commenter.id, + body: 'Nice post!', + }); + }); + + it('returns 404 when the post does not exist', async () => { + const user = await registerUser(app); + const res = await app.request('/posts/4f8a2c1e-0000-4000-8000-00000000dead/comments', { + method: 'POST', + headers: jsonHeaders(user.accessToken), + body: JSON.stringify({ body: 'Into the void' }), + }); + expect(res.status).toBe(404); + }); +}); + +describe('GET /posts/:postId/comments', () => { + it('lists comments oldest-first with public author info and meta', async () => { + const author = await registerUser(app, { name: 'Author' }); + const commenter = await registerUser(app, { name: 'Commenter' }); + const post = await createPost(app, author.accessToken); + await addComment(commenter.accessToken, post.id, 'First!'); + await addComment(author.accessToken, post.id, 'Thanks for reading.'); + + const res = await app.request(`/posts/${post.id}/comments`); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { body: string; author: Record }[]; + meta: { total: number; limit: number; offset: number }; + }; + + expect(body.meta).toEqual({ total: 2, limit: 20, offset: 0 }); + expect(body.data.map((c) => c.body)).toEqual(['First!', 'Thanks for reading.']); + expect(body.data[0]?.author).toEqual({ id: commenter.id, name: 'Commenter' }); + }); + + it('paginates comments', async () => { + const user = await registerUser(app); + const post = await createPost(app, user.accessToken); + for (let i = 1; i <= 3; i += 1) { + await addComment(user.accessToken, post.id, `Comment ${String(i)}`); + } + + const res = await app.request(`/posts/${post.id}/comments?limit=2&offset=2`); + const body = (await res.json()) as { data: { body: string }[]; meta: { total: number } }; + expect(body.meta.total).toBe(3); + expect(body.data.map((c) => c.body)).toEqual(['Comment 3']); + }); + + it('returns 404 for comments of a missing post', async () => { + const res = await app.request('/posts/4f8a2c1e-0000-4000-8000-00000000dead/comments'); + expect(res.status).toBe(404); + const body = (await res.json()) as ErrorBody; + expect(body.error.code).toBe('NOT_FOUND'); + }); +}); + +describe('DELETE /comments/:id', () => { + it('lets the comment author delete it', async () => { + const author = await registerUser(app); + const commenter = await registerUser(app); + const post = await createPost(app, author.accessToken); + const commentId = await addComment(commenter.accessToken, post.id, 'Delete me'); + + const res = await app.request(`/comments/${commentId}`, { + method: 'DELETE', + headers: jsonHeaders(commenter.accessToken), + }); + expect(res.status).toBe(204); + + const list = await app.request(`/posts/${post.id}/comments`); + const body = (await list.json()) as { meta: { total: number } }; + expect(body.meta.total).toBe(0); + }); + + it("forbids deleting someone else's comment", async () => { + const author = await registerUser(app); + const commenter = await registerUser(app); + const post = await createPost(app, author.accessToken); + const commentId = await addComment(commenter.accessToken, post.id, 'Mine'); + + const res = await app.request(`/comments/${commentId}`, { + method: 'DELETE', + headers: jsonHeaders(author.accessToken), + }); + expect(res.status).toBe(403); + }); + + it('returns 404 for an unknown comment', async () => { + const user = await registerUser(app); + const res = await app.request('/comments/4f8a2c1e-0000-4000-8000-00000000dead', { + method: 'DELETE', + headers: jsonHeaders(user.accessToken), + }); + expect(res.status).toBe(404); + }); +}); + +describe('cascade behaviour', () => { + it('deleting a post removes its comments at the database level', async () => { + const user = await registerUser(app); + const post = await createPost(app, user.accessToken); + await addComment(user.accessToken, post.id, 'Will cascade'); + + const del = await app.request(`/posts/${post.id}`, { + method: 'DELETE', + headers: jsonHeaders(user.accessToken), + }); + expect(del.status).toBe(204); + + const [row] = await testDb.db.select({ total: count() }).from(comments); + expect(row?.total).toBe(0); + }); +}); diff --git a/examples/blog-api-node/api/tests/integration/posts.test.ts b/examples/blog-api-node/api/tests/integration/posts.test.ts new file mode 100644 index 0000000..873e249 --- /dev/null +++ b/examples/blog-api-node/api/tests/integration/posts.test.ts @@ -0,0 +1,258 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createApp } from '../../src/app.js'; +import { + createPost, + createTestDb, + jsonHeaders, + registerUser, + type TestApp, + type TestDb, +} from '../helpers.js'; + +interface ErrorBody { + error: { code: string; message: string }; +} + +let testDb: TestDb; +let app: TestApp; + +beforeAll(async () => { + testDb = await createTestDb(); + app = createApp(testDb.db); +}); + +afterAll(async () => { + await testDb.cleanup(); +}); + +beforeEach(async () => { + await testDb.reset(); +}); + +describe('POST /posts', () => { + it('requires authentication', async () => { + const res = await app.request('/posts', { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ title: 'Nope', content: 'No token.' }), + }); + expect(res.status).toBe(401); + }); + + it('creates a post owned by the caller', async () => { + const user = await registerUser(app); + const res = await app.request('/posts', { + method: 'POST', + headers: jsonHeaders(user.accessToken), + body: JSON.stringify({ title: 'First post', content: 'Hello world.' }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as { data: { id: string; authorId: string; title: string } }; + expect(body.data.authorId).toBe(user.id); + expect(body.data.title).toBe('First post'); + }); + + it('rejects an empty title with 400 VALIDATION_ERROR', async () => { + const user = await registerUser(app); + const res = await app.request('/posts', { + method: 'POST', + headers: jsonHeaders(user.accessToken), + body: JSON.stringify({ title: '', content: 'Body.' }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as ErrorBody; + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); +}); + +describe('GET /posts', () => { + it('lists posts newest-first with the author embedded (public fields only)', async () => { + const user = await registerUser(app, { name: 'Author Jane' }); + await createPost(app, user.accessToken, { title: 'Older' }); + await createPost(app, user.accessToken, { title: 'Newer' }); + + const res = await app.request('/posts'); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { title: string; author: Record }[]; + meta: { total: number }; + }; + + expect(body.meta.total).toBe(2); + expect(body.data.map((p) => p.title)).toEqual(['Newer', 'Older']); + expect(body.data[0]?.author).toEqual({ id: user.id, name: 'Author Jane' }); + }); + + it('paginates with limit/offset and reports meta', async () => { + const user = await registerUser(app); + for (let i = 1; i <= 5; i += 1) { + await createPost(app, user.accessToken, { title: `Post ${String(i)}` }); + } + + const firstPage = await app.request('/posts?limit=2&offset=0'); + const firstBody = (await firstPage.json()) as { + data: { title: string }[]; + meta: { total: number; limit: number; offset: number }; + }; + expect(firstBody.meta).toEqual({ total: 5, limit: 2, offset: 0 }); + expect(firstBody.data.map((p) => p.title)).toEqual(['Post 5', 'Post 4']); + + const lastPage = await app.request('/posts?limit=2&offset=4'); + const lastBody = (await lastPage.json()) as { data: { title: string }[] }; + expect(lastBody.data.map((p) => p.title)).toEqual(['Post 1']); + }); + + it('rejects an out-of-range limit', async () => { + const res = await app.request('/posts?limit=500'); + expect(res.status).toBe(400); + }); +}); + +describe('GET /posts/:id', () => { + it('returns the post with its author', async () => { + const user = await registerUser(app); + const post = await createPost(app, user.accessToken); + + const res = await app.request(`/posts/${post.id}`); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { id: string; author: { id: string } } }; + expect(body.data.id).toBe(post.id); + expect(body.data.author.id).toBe(user.id); + }); + + it('returns 404 for a missing post', async () => { + const res = await app.request('/posts/4f8a2c1e-0000-4000-8000-00000000dead'); + expect(res.status).toBe(404); + const body = (await res.json()) as ErrorBody; + expect(body.error.code).toBe('NOT_FOUND'); + }); + + it('returns 400 for a malformed id', async () => { + const res = await app.request('/posts/not-a-uuid'); + expect(res.status).toBe(400); + }); +}); + +describe('PATCH /posts/:id', () => { + it('lets the author update title and content', async () => { + const user = await registerUser(app); + const post = await createPost(app, user.accessToken, { title: 'Draft' }); + + const res = await app.request(`/posts/${post.id}`, { + method: 'PATCH', + headers: jsonHeaders(user.accessToken), + body: JSON.stringify({ title: 'Published' }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { title: string; content: string } }; + expect(body.data.title).toBe('Published'); + expect(body.data.content).toBe(post.content); + }); + + it('updates only the content when the title is omitted', async () => { + const user = await registerUser(app); + const post = await createPost(app, user.accessToken, { title: 'Keep me' }); + + const res = await app.request(`/posts/${post.id}`, { + method: 'PATCH', + headers: jsonHeaders(user.accessToken), + body: JSON.stringify({ content: 'Rewritten body.' }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { title: string; content: string } }; + expect(body.data.title).toBe('Keep me'); + expect(body.data.content).toBe('Rewritten body.'); + }); + + it('forbids non-authors with 403 FORBIDDEN', async () => { + const author = await registerUser(app); + const intruder = await registerUser(app); + const post = await createPost(app, author.accessToken); + + const res = await app.request(`/posts/${post.id}`, { + method: 'PATCH', + headers: jsonHeaders(intruder.accessToken), + body: JSON.stringify({ title: 'Hijacked' }), + }); + + expect(res.status).toBe(403); + const body = (await res.json()) as ErrorBody; + expect(body.error.code).toBe('FORBIDDEN'); + }); + + it('rejects an empty patch body', async () => { + const user = await registerUser(app); + const post = await createPost(app, user.accessToken); + + const res = await app.request(`/posts/${post.id}`, { + method: 'PATCH', + headers: jsonHeaders(user.accessToken), + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + }); +}); + +describe('DELETE /posts/:id', () => { + it('lets the author delete; the post is gone afterwards', async () => { + const user = await registerUser(app); + const post = await createPost(app, user.accessToken); + + const del = await app.request(`/posts/${post.id}`, { + method: 'DELETE', + headers: jsonHeaders(user.accessToken), + }); + expect(del.status).toBe(204); + + const get = await app.request(`/posts/${post.id}`); + expect(get.status).toBe(404); + }); + + it('forbids non-authors', async () => { + const author = await registerUser(app); + const intruder = await registerUser(app); + const post = await createPost(app, author.accessToken); + + const res = await app.request(`/posts/${post.id}`, { + method: 'DELETE', + headers: jsonHeaders(intruder.accessToken), + }); + expect(res.status).toBe(403); + }); +}); + +describe('GET /users/:id and /users/:id/posts', () => { + it('exposes a public profile without the email', async () => { + const user = await registerUser(app, { name: 'Public Pat' }); + const res = await app.request(`/users/${user.id}`); + + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Record }; + expect(body.data['name']).toBe('Public Pat'); + expect(body.data).not.toHaveProperty('email'); + expect(body.data).not.toHaveProperty('passwordHash'); + }); + + it('lists a user posts with pagination meta', async () => { + const author = await registerUser(app); + const other = await registerUser(app); + await createPost(app, author.accessToken, { title: 'Mine' }); + await createPost(app, other.accessToken, { title: 'Not mine' }); + + const res = await app.request(`/users/${author.id}/posts`); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { title: string }[]; meta: { total: number } }; + expect(body.meta.total).toBe(1); + expect(body.data[0]?.title).toBe('Mine'); + }); + + it('returns 404 for an unknown user', async () => { + const res = await app.request('/users/4f8a2c1e-0000-4000-8000-00000000dead/posts'); + expect(res.status).toBe(404); + }); +}); diff --git a/examples/blog-api-node/api/tests/setup.ts b/examples/blog-api-node/api/tests/setup.ts new file mode 100644 index 0000000..a870228 --- /dev/null +++ b/examples/blog-api-node/api/tests/setup.ts @@ -0,0 +1,10 @@ +// Set required environment variables before any module imports them. +// src/config.ts parses process.env at module load, so these must be present +// before the first app/route import. DATABASE_URL is a placeholder: tests +// run against an in-process PGlite database (see tests/helpers.ts), never a +// real PostgreSQL server. +process.env['NODE_ENV'] ??= 'test'; +process.env['DATABASE_URL'] ??= 'postgresql://test:test@localhost:5432/test'; +process.env['JWT_SECRET'] ??= 'test-secret-for-integration-tests-min-32-chars'; +process.env['APP_VERSION'] ??= '0.0.1-test'; +process.env['HEALTH_DB_TIMEOUT_MS'] ??= '2000'; diff --git a/examples/blog-api-node/api/tests/unit/health.test.ts b/examples/blog-api-node/api/tests/unit/health.test.ts new file mode 100644 index 0000000..377aac8 --- /dev/null +++ b/examples/blog-api-node/api/tests/unit/health.test.ts @@ -0,0 +1,46 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createApp } from '../../src/app.js'; +import type { Database } from '../../src/db/client.js'; +import { createTestDb, type TestApp, type TestDb } from '../helpers.js'; + +describe('GET /health', () => { + let testDb: TestDb; + let app: TestApp; + + beforeAll(async () => { + testDb = await createTestDb(); + app = createApp(testDb.db); + }); + + afterAll(async () => { + await testDb.cleanup(); + }); + + it('reports healthy when the database responds', async () => { + const res = await app.request('/health'); + expect(res.status).toBe(200); + const body = (await res.json()) as { + status: string; + checks: { database: string }; + requestId: string; + uptime: number; + }; + expect(body.status).toBe('healthy'); + expect(body.checks.database).toBe('connected'); + expect(body.requestId).toBeTruthy(); + expect(body.uptime).toBeGreaterThanOrEqual(0); + }); + + it('reports unhealthy with 503 when the database is down', async () => { + const brokenDb = { + execute: () => Promise.reject(new Error('connection refused')), + } as unknown as Database; + const brokenApp = createApp(brokenDb); + + const res = await brokenApp.request('/health'); + expect(res.status).toBe(503); + const body = (await res.json()) as { status: string; checks: { database: string } }; + expect(body.status).toBe('unhealthy'); + expect(body.checks.database).toBe('disconnected'); + }); +}); diff --git a/examples/blog-api-node/api/tests/unit/pagination.test.ts b/examples/blog-api-node/api/tests/unit/pagination.test.ts new file mode 100644 index 0000000..0c4bed1 --- /dev/null +++ b/examples/blog-api-node/api/tests/unit/pagination.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { pageMeta, paginationQuerySchema } from '../../src/lib/pagination.js'; + +describe('paginationQuerySchema', () => { + it('applies defaults when no params are given', () => { + expect(paginationQuerySchema.parse({})).toEqual({ limit: 20, offset: 0 }); + }); + + it('coerces query-string values to numbers', () => { + expect(paginationQuerySchema.parse({ limit: '5', offset: '10' })).toEqual({ + limit: 5, + offset: 10, + }); + }); + + it('rejects out-of-range values', () => { + expect(paginationQuerySchema.safeParse({ limit: '0' }).success).toBe(false); + expect(paginationQuerySchema.safeParse({ limit: '101' }).success).toBe(false); + expect(paginationQuerySchema.safeParse({ offset: '-1' }).success).toBe(false); + expect(paginationQuerySchema.safeParse({ limit: 'abc' }).success).toBe(false); + }); +}); + +describe('pageMeta', () => { + it('echoes total alongside the requested window', () => { + expect(pageMeta(42, { limit: 10, offset: 20 })).toEqual({ total: 42, limit: 10, offset: 20 }); + }); +}); diff --git a/examples/blog-api-node/api/tests/unit/password.test.ts b/examples/blog-api-node/api/tests/unit/password.test.ts new file mode 100644 index 0000000..ecd24ee --- /dev/null +++ b/examples/blog-api-node/api/tests/unit/password.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { hashPassword, verifyPassword } from '../../src/lib/password.js'; + +describe('password hashing', () => { + it('verifies a correct password', async () => { + const hash = await hashPassword('password123'); + await expect(verifyPassword('password123', hash)).resolves.toBe(true); + }); + + it('rejects a wrong password', async () => { + const hash = await hashPassword('password123'); + await expect(verifyPassword('not-the-password', hash)).resolves.toBe(false); + }); + + it('salts hashes (same password, different hashes)', async () => { + const first = await hashPassword('password123'); + const second = await hashPassword('password123'); + expect(first).not.toBe(second); + }); + + it('rejects malformed stored hashes instead of throwing', async () => { + await expect(verifyPassword('password123', 'not-a-valid-hash')).resolves.toBe(false); + await expect(verifyPassword('password123', '')).resolves.toBe(false); + }); +}); diff --git a/examples/blog-api-node/api/tests/unit/tokens.test.ts b/examples/blog-api-node/api/tests/unit/tokens.test.ts new file mode 100644 index 0000000..5db3a3e --- /dev/null +++ b/examples/blog-api-node/api/tests/unit/tokens.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { issueTokenPair, verifyToken } from '../../src/lib/tokens.js'; + +const USER_ID = '4f8a2c1e-0000-4000-8000-000000000001'; + +describe('token pair', () => { + it('issues an access token that verifies as access', async () => { + const { accessToken } = await issueTokenPair(USER_ID); + await expect(verifyToken(accessToken, 'access')).resolves.toBe(USER_ID); + }); + + it('issues a refresh token that verifies as refresh', async () => { + const { refreshToken } = await issueTokenPair(USER_ID); + await expect(verifyToken(refreshToken, 'refresh')).resolves.toBe(USER_ID); + }); + + it('rejects an access token presented as a refresh token (and vice versa)', async () => { + const { accessToken, refreshToken } = await issueTokenPair(USER_ID); + await expect(verifyToken(accessToken, 'refresh')).resolves.toBeNull(); + await expect(verifyToken(refreshToken, 'access')).resolves.toBeNull(); + }); + + it('rejects tampered and garbage tokens', async () => { + const { accessToken } = await issueTokenPair(USER_ID); + const tampered = `${accessToken.slice(0, -2)}xx`; + await expect(verifyToken(tampered, 'access')).resolves.toBeNull(); + await expect(verifyToken('not.a.jwt', 'access')).resolves.toBeNull(); + }); +}); diff --git a/examples/blog-api-node/api/tsconfig.base.json b/examples/blog-api-node/api/tsconfig.base.json new file mode 100644 index 0000000..bde53c6 --- /dev/null +++ b/examples/blog-api-node/api/tsconfig.base.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": [ + "ES2022" + ], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts" + ] +} diff --git a/examples/blog-api-node/api/tsconfig.json b/examples/blog-api-node/api/tsconfig.json new file mode 100644 index 0000000..14e2c7f --- /dev/null +++ b/examples/blog-api-node/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "types": [ + "node" + ] + } +} diff --git a/examples/blog-api-node/api/vitest.config.ts b/examples/blog-api-node/api/vitest.config.ts new file mode 100644 index 0000000..74acfa6 --- /dev/null +++ b/examples/blog-api-node/api/vitest.config.ts @@ -0,0 +1,46 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + globals: true, + environment: 'node', + include: ['src/**/*.{test,spec}.ts', 'tests/**/*.{test,spec}.ts'], + exclude: ['node_modules', 'dist', 'tests/load/**'], + setupFiles: ['./tests/setup.ts'], + testTimeout: 10_000, + hookTimeout: 30_000, + coverage: { + provider: 'v8', + reporter: ['text', 'text-summary', 'lcov', 'json'], + include: ['src/**/*.ts'], + exclude: [ + 'src/**/*.test.ts', + 'src/**/*.spec.ts', + 'src/**/*.d.ts', + 'src/db/migrations/**', + 'src/db/seed.ts', + // Composition roots: the server entry wires the real postgres-js + // pool; tests exercise the same app via createApp + PGlite instead. + // The deploy-time migration runner is covered indirectly — tests + // apply the same committed migrations through the PGlite migrator. + 'src/index.ts', + 'src/db/client.ts', + 'src/db/migrate.ts', + ], + thresholds: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + pool: 'forks', + reporters: ['verbose'], + }, +});