From 1589f122775fb6beb7aaefb63ddcf63fe614ee58 Mon Sep 17 00:00:00 2001
From: Erik Hjelm Fjeldheim
Date: Wed, 23 Sep 2026 11:45:41 -0600
Subject: [PATCH] =?UTF-8?q?feat:=20#1=20scaffold=20Crate=20=E2=80=94=20mon?=
=?UTF-8?q?orepo,=20GraphQL=20API,=20Postgres,=20React=20client,=20tests,?=
=?UTF-8?q?=20docs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Brings up the whole stack for Project 2 so the group can split the work.
server/
GraphQL Yoga + Drizzle + PostgreSQL 16. Hand-written SDL in
server/src/schema.graphql: searchAlbums returns a Relay-style connection
with totalCount and facet counts alongside the page of edges.
- Postgres FTS on a trigger-maintained search_vector (GIN) — single-term,
case-insensitive search that stays fast as the catalog grows
- keyset/cursor pagination in search/search.ts (never OFFSET)
- sorting and filtering are SQL WHERE / ORDER BY over the whole result set
- per-request DataLoaders so nested fields never degenerate into N+1
- drizzle/0000_init.sql + idempotent migrate.ts
- seed.ts (20 curated albums) and generate.ts (ALBUM_COUNT up to 1M)
client/
React 19 + Vite + TS + Apollo Client. InMemoryCache typePolicies merge
edges across fetchMore; query/filters/sort live in the URL so reload,
back/forward and shared links all work.
- search bar with debounce + explicit submit
- facet panel with counts (categories that would match zero are hidden)
- sort control, infinite scroll with a keyboard-operable Load more button
- detail view (code-split) with tracklist, reviews, crate toggle
- star rating as a radio group, skip link, aria-live result count
- simulated user identity (UUID in localStorage) as the lecture allows
e2e/
Playwright journey (search -> filter -> sort -> review -> reload) plus
an axe-core scan.
docs/
architecture.md (10 ADRs), VM-setup.md (runbook), AI-USAGE.md (log),
AGENTS.md. scripts/ has the one-shot VM bootstrap.
CI/CD
Self-hosted runner workflow: format, lint, typecheck, test, build,
bundle-size budget, then deploy to Apache + restart crate-api.
Verified live on it2810-19.idi.ntnu.no: search, facets, cursor paging,
server-side rating sort, review/crate mutations, nested detail, proxy.
Co-Authored-By: opencode
---
.env.example | 6 +
.github/workflows/ci.yml | 111 +
.gitignore | 14 +
.prettierignore | 9 +
.prettierrc.json | 6 +
AGENTS.md | 86 +
README.md | 130 +-
client/index.html | 13 +
client/package.json | 35 +
client/src/App.css | 424 ++
client/src/App.tsx | 159 +
client/src/api/client.ts | 27 +
client/src/api/queries.ts | 142 +
client/src/components/AlbumCard.tsx | 53 +
client/src/components/AlbumDetail.tsx | 167 +
client/src/components/AlbumList.tsx | 77 +
client/src/components/FilterPanel.tsx | 77 +
client/src/components/ReviewForm.tsx | 88 +
client/src/components/SearchBar.tsx | 42 +
client/src/components/SortSelect.tsx | 39 +
client/src/components/StarRating.tsx | 54 +
client/src/components/components.test.tsx | 120 +
client/src/hooks/hooks.test.ts | 71 +
client/src/hooks/useDebounce.ts | 17 +
client/src/hooks/useSearchState.ts | 77 +
client/src/hooks/useUserId.ts | 54 +
client/src/index.css | 10 +
client/src/main.tsx | 17 +
client/src/test/handlers.ts | 81 +
client/src/test/setup.ts | 14 +
client/src/types.ts | 63 +
client/tsconfig.json | 10 +
client/tsconfig.tsbuildinfo | 1 +
client/vite.config.ts | 23 +
client/vitest.config.ts | 12 +
docs/AI-USAGE.md | 95 +
docs/VM-setup.md | 138 +
docs/architecture.md | 119 +
e2e/package.json | 16 +
e2e/playwright.config.ts | 25 +
e2e/tests/search.spec.ts | 81 +
eslint.config.js | 47 +
package.json | 46 +
pnpm-lock.yaml | 5572 +++++++++++++++++++++
pnpm-workspace.yaml | 4 +
scripts/crate-vm-bootstrap-2.sh | 71 +
scripts/crate-vm-bootstrap.sh | 115 +
server/drizzle.config.ts | 10 +
server/drizzle/0000_init.sql | 111 +
server/package.json | 35 +
server/src/db/client.ts | 18 +
server/src/db/env.ts | 20 +
server/src/db/migrate.ts | 56 +
server/src/db/schema.ts | 139 +
server/src/db/seed/generate.ts | 189 +
server/src/db/seed/seed.ts | 475 ++
server/src/index.ts | 62 +
server/src/loaders/index.ts | 135 +
server/src/resolvers/index.ts | 267 +
server/src/resolvers/search.ts | 186 +
server/src/schema.graphql | 181 +
server/src/search/search.ts | 207 +
server/src/types.ts | 17 +
server/test/search.test.ts | 168 +
server/tsconfig.json | 9 +
server/vitest.config.ts | 8 +
tsconfig.base.json | 20 +
67 files changed, 10955 insertions(+), 16 deletions(-)
create mode 100644 .env.example
create mode 100644 .github/workflows/ci.yml
create mode 100644 .gitignore
create mode 100644 .prettierignore
create mode 100644 .prettierrc.json
create mode 100644 AGENTS.md
create mode 100644 client/index.html
create mode 100644 client/package.json
create mode 100644 client/src/App.css
create mode 100644 client/src/App.tsx
create mode 100644 client/src/api/client.ts
create mode 100644 client/src/api/queries.ts
create mode 100644 client/src/components/AlbumCard.tsx
create mode 100644 client/src/components/AlbumDetail.tsx
create mode 100644 client/src/components/AlbumList.tsx
create mode 100644 client/src/components/FilterPanel.tsx
create mode 100644 client/src/components/ReviewForm.tsx
create mode 100644 client/src/components/SearchBar.tsx
create mode 100644 client/src/components/SortSelect.tsx
create mode 100644 client/src/components/StarRating.tsx
create mode 100644 client/src/components/components.test.tsx
create mode 100644 client/src/hooks/hooks.test.ts
create mode 100644 client/src/hooks/useDebounce.ts
create mode 100644 client/src/hooks/useSearchState.ts
create mode 100644 client/src/hooks/useUserId.ts
create mode 100644 client/src/index.css
create mode 100644 client/src/main.tsx
create mode 100644 client/src/test/handlers.ts
create mode 100644 client/src/test/setup.ts
create mode 100644 client/src/types.ts
create mode 100644 client/tsconfig.json
create mode 100644 client/tsconfig.tsbuildinfo
create mode 100644 client/vite.config.ts
create mode 100644 client/vitest.config.ts
create mode 100644 docs/AI-USAGE.md
create mode 100644 docs/VM-setup.md
create mode 100644 docs/architecture.md
create mode 100644 e2e/package.json
create mode 100644 e2e/playwright.config.ts
create mode 100644 e2e/tests/search.spec.ts
create mode 100644 eslint.config.js
create mode 100644 package.json
create mode 100644 pnpm-lock.yaml
create mode 100644 pnpm-workspace.yaml
create mode 100644 scripts/crate-vm-bootstrap-2.sh
create mode 100644 scripts/crate-vm-bootstrap.sh
create mode 100644 server/drizzle.config.ts
create mode 100644 server/drizzle/0000_init.sql
create mode 100644 server/package.json
create mode 100644 server/src/db/client.ts
create mode 100644 server/src/db/env.ts
create mode 100644 server/src/db/migrate.ts
create mode 100644 server/src/db/schema.ts
create mode 100644 server/src/db/seed/generate.ts
create mode 100644 server/src/db/seed/seed.ts
create mode 100644 server/src/index.ts
create mode 100644 server/src/loaders/index.ts
create mode 100644 server/src/resolvers/index.ts
create mode 100644 server/src/resolvers/search.ts
create mode 100644 server/src/schema.graphql
create mode 100644 server/src/search/search.ts
create mode 100644 server/src/types.ts
create mode 100644 server/test/search.test.ts
create mode 100644 server/tsconfig.json
create mode 100644 server/vitest.config.ts
create mode 100644 tsconfig.base.json
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..6fb50cf
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,6 @@
+# Server
+PORT=3001
+DATABASE_URL=postgres://t19:t19@localhost:5432/crate
+
+# Client (Vite)
+VITE_GRAPHQL_URL=http://localhost:3001/graphql
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e3d4467
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,111 @@
+name: CI/CD
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ quality:
+ name: Format, lint, typecheck, test, build
+ runs-on: [self-hosted, linux, x64]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10.33.0
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Check formatting
+ run: pnpm format:check
+
+ - name: Lint
+ run: pnpm lint
+
+ - name: Typecheck
+ run: pnpm typecheck
+
+ - name: Unit tests (server + client)
+ run: pnpm test
+
+ - name: Build (server + client)
+ run: pnpm build
+
+ # Bundle budget: fail the build if the main JS chunk creeps past 500 kB.
+ # This is one of our documented bærekraft measures — see docs/architecture.md.
+ - name: Enforce client bundle budget
+ run: |
+ set -euo pipefail
+ MAX_BYTES=512000
+ for f in client/dist/assets/*.js; do
+ size=$(stat -c%s "$f")
+ echo "$f = ${size} bytes"
+ if [ "$size" -gt "$MAX_BYTES" ]; then
+ echo "::error::Bundle budget exceeded: $f is ${size} bytes (max ${MAX_BYTES})."
+ exit 1
+ fi
+ done
+
+ deploy:
+ name: Deploy to the group VM
+ needs: quality
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+ runs-on: [self-hosted, linux, x64]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10.33.0
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Build server and client
+ run: pnpm build
+ env:
+ VITE_GRAPHQL_URL: /graphql
+
+ - name: Deploy client to Apache
+ run: |
+ set -euo pipefail
+ find /var/www/html/project2 -mindepth 1 -delete
+ cp -r client/dist/. /var/www/html/project2/
+ chmod -R a+rX /var/www/html/project2
+
+ - name: Restart the GraphQL API
+ run: sudo systemctl restart crate-api
+
+ - name: Smoke check
+ run: |
+ set -euo pipefail
+ sleep 2
+ curl -fsS -o /dev/null -w 'GET /project2/ : %{http_code}\n' http://localhost/project2/
+ curl -fsS -o /dev/null -w 'POST /graphql : %{http_code}\n' \
+ -X POST http://localhost/graphql \
+ -H 'content-type: application/json' \
+ -d '{"query":"{ genres { name } }"}'
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..99a2624
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+node_modules
+dist
+coverage
+.env
+.env.local
+*.log
+.DS_Store
+e2e/test-results
+e2e/playwright-report
+e2e/.cache
+server/src/generated
+client/src/gql/graphql.ts
+.vscode
+.idea
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..47355b5
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,9 @@
+dist
+coverage
+node_modules
+pnpm-lock.yaml
+*.min.js
+client/src/gql
+server/src/generated
+e2e/test-results
+e2e/playwright-report
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..fe2b548
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,6 @@
+{
+ "singleQuote": true,
+ "trailingComma": "all",
+ "printWidth": 90,
+ "semi": true
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..f9a1d93
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,86 @@
+# AGENTS.md
+
+Guidance for AI coding agents and human contributors working in this repository.
+
+## Project
+
+**Crate** — community album catalog for IT2810 (NTNU) Project 2. Search, filter
+and sort albums; open a detail view; persist ratings, reviews and a personal
+crate.
+
+- pnpm workspace: `client/` (React 19 + Vite + TS + Apollo Client), `server/`
+ (GraphQL Yoga + Drizzle + PostgreSQL), `e2e/` (Playwright + axe-core).
+- Node.js 24.x, pnpm 10.x.
+- TypeScript strict mode with `noUnusedLocals` and `noUncheckedIndexedAccess`.
+
+## Commands
+
+| Command | Purpose |
+| --------------------------------------------------------- | ----------------------------------------------- |
+| `pnpm install` | Install all workspaces |
+| `pnpm dev` | API (:3001) + client (:5173) in watch mode |
+| `pnpm build` | Production build of server + client |
+| `pnpm lint` | ESLint, must pass with 0 warnings |
+| `pnpm format` / `pnpm format:check` | Prettier |
+| `pnpm typecheck` | `tsc --noEmit` in every workspace |
+| `pnpm test` | Vitest for server + client |
+| `pnpm test:e2e` | Playwright (needs API + client preview running) |
+| `pnpm db:migrate` / `pnpm db:seed` / `pnpm db:seed:large` | Schema / data |
+
+Run `pnpm format:check && pnpm lint && pnpm typecheck && pnpm test` before a PR.
+
+## Architecture rules (do not casually change these)
+
+1. **Sorting and filtering happen in SQL**, never by rearranging a loaded page
+ in the client. `searchAlbums` arguments map to `WHERE` / `ORDER BY`.
+2. **Pagination is keyset/cursor-based** (`first` + `after`), not `OFFSET`. See
+ `server/src/search/search.ts`.
+3. **Every relation field goes through the per-request DataLoaders** in
+ `server/src/loaders/index.ts`. Do not call `db.select` from a field resolver.
+4. **User input is never interpolated into SQL.** Use drizzle `sql` templates so
+ values are bound parameters (there is a unit test for this).
+5. **Users are simulated**: a UUID in `localStorage` (`t19.crate.user`). No
+ authentication. Mutations take `userId` + `displayName` and upsert into
+ `"user"`.
+6. **Plain CSS only** (one file, `client/src/App.css`). No CSS framework, no
+ inline style objects for static styling. Respect `prefers-color-scheme` and
+ `prefers-reduced-motion`. Visible focus rings are required.
+7. **GraphQL schema lives in `server/src/schema.graphql`** and is the contract.
+ Resolvers must not return shapes that deviate from it.
+8. **Accessibility is not optional**: form controls need labels or
+ `aria-label`, new async content needs `role="status"` + `aria-live="polite"`,
+ interactive star ratings must be a radio group.
+
+## Layout
+
+```
+server/src/
+ schema.graphql GraphQL SDL
+ resolvers/ Query, Mutation and per-type field resolvers
+ search/ pure WHERE / ORDER BY / cursor builders (unit-tested)
+ loaders/ DataLoader batchers (N+1 defence)
+ db/ Drizzle schema, migrations (drizzle/*.sql), seed scripts
+client/src/
+ api/ Apollo Client instance + GraphQL documents
+ components/ presentational + container components
+ hooks/ useDebounce, useSearchState (URL), useUserId
+ test/ Vitest setup + MSW handlers
+e2e/tests/ Playwright journeys (search → filter → sort → review)
+docs/ architecture.md, VM-setup.md, AI-USAGE.md
+```
+
+## Conventions
+
+- One component per file; props-driven; derived logic in hooks.
+- Conventional commits referencing the issue (`feat: #12 add decade facet`).
+- Branch names: `feat/-short-name`, `fix/-short-name`.
+- Every AI-assisted change is logged in `docs/AI-USAGE.md` and goes through
+ normal peer review (see `CODEOWNERS`).
+
+## Infrastructure
+
+- Backend listens on port **3001**; database is only reachable from the VM.
+- Client is built with `base: '/project2/'` and served by Apache at
+ `http://it2810-19.idi.ntnu.no/project2`.
+- CI runs on the self-hosted runner on the group VM. Full VM runbook:
+ `docs/VM-setup.md`.
diff --git a/README.md b/README.md
index a181cf8..3b44ef3 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,122 @@
-# T19-Project-2
+# Crate — community album catalog
-Repository for team IT2810-H26-T19 in IT2810-H26.
+**IT2810 Project 2 · Team T19** · live at `http://it2810-19.idi.ntnu.no/project2`
-## Working in this repository
+Crate is a searchable catalog of albums. You search by word (case-insensitive),
+filter by genre and decade, sort the **whole** result set, page through large
+results, open an album to see its tracklist and community reviews, and add
+your own rating, review and wantlist ("crate").
-`main` is protected — you cannot push to it directly. Work on a branch and
-open a pull request:
+---
+
+## Quick start
+
+Requirements: **Node.js 24+** and **pnpm 10+** (see [nvm](https://github.com/nvm-sh/nvm)),
+and a local **PostgreSQL 16+** (only needed for the API).
```bash
-git checkout -b my-feature
-git push -u origin my-feature
+# 1. Install workspace dependencies
+pnpm install
+
+# 2. Create a database and user (once)
+sudo -u postgres psql <<'SQL'
+CREATE USER t19 WITH PASSWORD 't19';
+CREATE DATABASE crate OWNER t19;
+SQL
+
+# 3. Configure the server
+cp .env.example .env # adjust DATABASE_URL if needed
+
+# 4. Create tables and seed a sample catalog
+pnpm db:migrate
+pnpm db:seed # ~20 curated albums
+pnpm db:seed:large # optional: +100k synthetic albums (ALBUM_COUNT=…)
+
+# 5. Run both apps (API on :3001, client on :5173)
+pnpm dev
+```
+
+Open .
+
+| Command | Purpose |
+| ---------------------------------- | --------------------------------- |
+| `pnpm dev` | API + client in watch mode |
+| `pnpm build` | Production build of both packages |
+| `pnpm lint` / `pnpm format` | ESLint (0 warnings) / Prettier |
+| `pnpm typecheck` | TypeScript, no emit |
+| `pnpm test` | Vitest for server + client |
+| `pnpm test:e2e` | Playwright end-to-end tests |
+| `pnpm db:migrate` / `pnpm db:seed` | Schema / sample data |
+
+Run `pnpm format:check && pnpm lint && pnpm typecheck && pnpm test` before opening a PR.
+
+---
+
+## What the application does
+
+| Requirement | How it is implemented |
+| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Search on single terms, case-insensitive, no wasted requests | Postgres full-text search (`websearch_to_tsquery('simple', …)` on a GIN-indexed `search_vector`), 300 ms debounce + explicit submit |
+| List presentation of large result sets | Infinite scroll with a keyboard-accessible **Load more** button. Cursor (keyset) pagination — see [docs/architecture.md](docs/architecture.md) |
+| Detail view | `/album/:id` with tracklist, artist, genres, community reviews |
+| Sort and filter on the **whole** result set | Both are SQL `ORDER BY` / `WHERE` clauses. Changing either re-queries the server; nothing is rearranged client-side |
+| Persistent user-generated data | Ratings (1–5) + written reviews + a personal crate, all in PostgreSQL. Users are simulated (UUID in `localStorage`) as the lecture allows |
+| Universal utforming | Skip link, landmarks, `aria-live` result count, radio-group star rating, visible focus, keyboard-operable everything. `axe-core` runs in the E2E suite |
+| Bærekraft | No UI framework, debounced queries, lazy images, `prefers-color-scheme` + `prefers-reduced-motion`, GraphQL fetches only the requested fields |
+| GraphQL backend | Hand-written schema + resolvers on GraphQL Yoga. DataLoaders remove the N+1 pattern |
+| State management | Apollo Client cache with a `typePolicies` merge for paginated results, plus URL state for query/filters/sort |
+| Testing | Vitest (server SQL builders + client components, MSW), Playwright E2E for the full journey |
+
+---
+
+## Architecture
+
+```
+client/ React 19 + Vite + TypeScript + Apollo Client (SPA)
+server/ GraphQL Yoga + Drizzle + PostgreSQL 16
+e2e/ Playwright + axe-core
```
-Before a pull request can be merged:
+The database is only reachable from the VM itself. The GraphQL API on port
+**3001** is the only thing exposed to the browser — matching the course
+requirement that the database and backend run _directly on the VM_ (no Docker,
+no external BaaS).
+
+Decisions and trade-offs (why PostgreSQL and not MongoDB/Neo4j, why cursor
+pagination, how N+1 is handled) are written up in
+[docs/architecture.md](docs/architecture.md). VM setup is in
+[docs/VM-setup.md](docs/VM-setup.md). AI usage is logged in
+[docs/AI-USAGE.md](docs/AI-USAGE.md).
+
+---
+
+## Project layout
+
+```
+server/src/
+ schema.graphql GraphQL SDL — the contract
+ resolvers/ Query/Mutation/field resolvers
+ search/ WHERE/ORDER BY/cursor builders (pure, unit-tested)
+ loaders/ per-request DataLoaders (N+1 defence)
+ db/ Drizzle schema, migrations, seed scripts
+client/src/
+ api/ Apollo Client + GraphQL documents
+ components/ SearchBar, FilterPanel, AlbumList, AlbumCard, …
+ hooks/ useDebounce, useSearchState (URL), useUserId
+e2e/tests/ Playwright journeys
+docs/ architecture, VM setup, AI usage
+```
+
+---
+
+## Workflow
+
+1. Pick an issue from the current milestone.
+2. Branch from `main` as `feat/-short-name`.
+3. Conventional commits referencing the issue.
+4. Open a PR. A teammate must approve (see `CODEOWNERS`).
+5. Resolve every review conversation, then merge.
-1. **A member of IT2810-H26-T19 must approve it.** You cannot approve your own pull
- request, and approvals from staff do not count — you review each other's
- code.
-2. **Every review comment must be marked resolved.** Reply to each one, fix it
- or explain why not, then click *Resolve conversation*.
-3. **Pushing new commits dismisses the approval**, so if you change something
- after being approved, ask for another review. This is deliberate: what was
- approved is what gets merged.
\ No newline at end of file
+CI runs on the self-hosted runner on the group VM: lint, typecheck, unit tests
+and a production build must pass before merge. Pushes to `main` deploy the
+client build and restart the API.
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..6010711
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Crate — album catalog
+
+
+
+
+
+
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..8774a95
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@t19/client",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc -b --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "@apollo/client": "^4.1.2",
+ "graphql": "^16.12.0",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
+ "react-router-dom": "^7.9.5"
+ },
+ "devDependencies": {
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.7",
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
+ "@vitejs/plugin-react": "^6.1.0",
+ "jsdom": "^29.1.1",
+ "msw": "^2.15.0",
+ "typescript": "~5.9.3",
+ "vite": "^8.2.2",
+ "vitest": "^4.1.11"
+ }
+}
diff --git a/client/src/App.css b/client/src/App.css
new file mode 100644
index 0000000..3e0feee
--- /dev/null
+++ b/client/src/App.css
@@ -0,0 +1,424 @@
+/* Crate — plain CSS. No framework, on purpose: smaller bundle (sustainability)
+ and full control over focus styles and contrast (accessibility). */
+
+:root {
+ color-scheme: light dark;
+ --bg: #fafaf8;
+ --surface: #ffffff;
+ --text: #1a1a1a;
+ --muted: #5c5c5c;
+ --border: #d8d8d4;
+ --accent: #2f5d50;
+ --accent-text: #ffffff;
+ --danger: #8b2222;
+ --star: #c9a227;
+ --focus: #1a73e8;
+ --radius: 8px;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
+ font-family:
+ system-ui,
+ -apple-system,
+ 'Segoe UI',
+ Roboto,
+ sans-serif;
+ line-height: 1.5;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #141414;
+ --surface: #1e1e1e;
+ --text: #f2f2f0;
+ --muted: #a8a8a4;
+ --border: #333;
+ --accent: #6fae9c;
+ --accent-text: #0d0d0d;
+ --danger: #e07a7a;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.skip-link {
+ position: absolute;
+ left: -9999px;
+ top: 0;
+ background: var(--accent);
+ color: var(--accent-text);
+ padding: 0.5rem 1rem;
+ z-index: 100;
+}
+.skip-link:focus {
+ left: 0;
+}
+
+:where(a, button, input, select, textarea, [tabindex]):focus-visible {
+ outline: 3px solid var(--focus);
+ outline-offset: 2px;
+}
+
+.app-header {
+ display: flex;
+ align-items: baseline;
+ gap: 1rem;
+ padding: 1rem 1.5rem;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+}
+.app-header h1 {
+ margin: 0;
+ font-size: 1.5rem;
+}
+.app-header a {
+ color: inherit;
+ text-decoration: none;
+}
+.tagline {
+ margin: 0;
+ color: var(--muted);
+}
+
+.app-footer {
+ padding: 1.5rem;
+ color: var(--muted);
+ border-top: 1px solid var(--border);
+ margin-top: 2rem;
+}
+
+main {
+ padding: 1.5rem;
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.search-bar {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+.search-bar input {
+ flex: 1;
+ padding: 0.6rem 0.8rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: 1rem;
+ background: var(--surface);
+ color: var(--text);
+}
+.search-bar button {
+ padding: 0.6rem 1.2rem;
+ border: none;
+ border-radius: var(--radius);
+ background: var(--accent);
+ color: var(--accent-text);
+ font-size: 1rem;
+ cursor: pointer;
+}
+
+.search-layout {
+ display: grid;
+ grid-template-columns: 240px 1fr;
+ gap: 1.5rem;
+}
+@media (max-width: 800px) {
+ .search-layout {
+ grid-template-columns: 1fr;
+ }
+}
+
+.filter-panel {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 1rem;
+ align-self: start;
+}
+.filter-panel h2 {
+ font-size: 1rem;
+ margin: 0 0 0.5rem;
+}
+.filter-panel section + section {
+ margin-top: 1.25rem;
+}
+.facet-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.facet-list label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.25rem 0;
+ cursor: pointer;
+}
+.facet-count {
+ margin-left: auto;
+ color: var(--muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.toolbar {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 0.75rem;
+}
+.sort-select {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+.sort-select select {
+ padding: 0.4rem;
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--text);
+}
+
+.result-count {
+ color: var(--muted);
+ margin-top: 0;
+}
+
+.album-grid {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 0.75rem;
+}
+
+.album-card {
+ display: grid;
+ grid-template-columns: 80px 1fr auto;
+ gap: 1rem;
+ align-items: center;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 0.75rem;
+ box-shadow: var(--shadow);
+}
+@media (max-width: 600px) {
+ .album-card {
+ grid-template-columns: 64px 1fr;
+ }
+ .crate-btn {
+ grid-column: 1 / -1;
+ }
+}
+
+.album-cover {
+ width: 80px;
+ height: 80px;
+ border-radius: 4px;
+ background: var(--border);
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ font-size: 1.75rem;
+ color: var(--muted);
+}
+.album-cover img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.album-body h3 {
+ margin: 0 0 0.15rem;
+ font-size: 1.05rem;
+}
+.album-body h3 a {
+ color: inherit;
+}
+.album-artist {
+ margin: 0;
+ color: var(--muted);
+}
+.album-meta {
+ margin: 0.25rem 0;
+ color: var(--muted);
+ font-size: 0.9rem;
+}
+.album-rating {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+.rating-count {
+ color: var(--muted);
+ font-size: 0.9rem;
+}
+
+.stars {
+ display: inline-flex;
+ gap: 0.1rem;
+ border: 0;
+ padding: 0;
+ margin: 0;
+}
+.star {
+ color: var(--border);
+ font-size: 1.1rem;
+}
+.star.on {
+ color: var(--star);
+}
+.stars-input label {
+ cursor: pointer;
+}
+.stars-input input {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+}
+.stars-input label:has(input:focus-visible) {
+ outline: 3px solid var(--focus);
+ outline-offset: 2px;
+ border-radius: 2px;
+}
+
+.crate-btn {
+ padding: 0.45rem 0.8rem;
+ border: 1px solid var(--accent);
+ background: transparent;
+ color: var(--accent);
+ border-radius: var(--radius);
+ cursor: pointer;
+ white-space: nowrap;
+}
+.crate-btn.in-crate {
+ background: var(--accent);
+ color: var(--accent-text);
+}
+
+.load-more {
+ display: flex;
+ justify-content: center;
+ padding: 1.5rem 0;
+}
+.load-more button {
+ padding: 0.6rem 1.5rem;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--text);
+ border-radius: var(--radius);
+ cursor: pointer;
+}
+.load-more button:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+.empty-hint {
+ color: var(--muted);
+}
+
+.album-detail header {
+ margin-bottom: 1.5rem;
+}
+.tracklist {
+ list-style: decimal;
+ padding-left: 1.5rem;
+}
+.tracklist li {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.2rem 0;
+ border-bottom: 1px solid var(--border);
+ max-width: 480px;
+}
+.track-duration {
+ color: var(--muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.review-list {
+ list-style: none;
+ padding: 0;
+}
+.review-list li {
+ padding: 0.75rem 0;
+ border-bottom: 1px solid var(--border);
+}
+.review-meta {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ color: var(--muted);
+ font-size: 0.95rem;
+}
+
+.review-form {
+ margin-top: 1.5rem;
+ padding: 1rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--surface);
+ display: grid;
+ gap: 0.5rem;
+ max-width: 560px;
+}
+.review-form textarea {
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--bg);
+ color: var(--text);
+ font: inherit;
+}
+.form-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+.form-actions button {
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: var(--radius);
+ background: var(--accent);
+ color: var(--accent-text);
+ cursor: pointer;
+}
+.form-actions button.danger {
+ background: transparent;
+ color: var(--danger);
+ border: 1px solid var(--danger);
+}
+.form-error {
+ color: var(--danger);
+ margin: 0;
+}
diff --git a/client/src/App.tsx b/client/src/App.tsx
new file mode 100644
index 0000000..2f15f4c
--- /dev/null
+++ b/client/src/App.tsx
@@ -0,0 +1,159 @@
+import { Suspense, lazy, useCallback, useMemo, useState } from 'react';
+import { Route, Routes } from 'react-router-dom';
+import { NetworkStatus } from '@apollo/client';
+import { useQuery, useMutation } from '@apollo/client/react';
+import { SEARCH_ALBUMS, TOGGLE_CRATE } from './api/queries.js';
+import { useSearchState } from './hooks/useSearchState.js';
+import { useDebounce } from './hooks/useDebounce.js';
+import { getIdentity } from './hooks/useUserId.js';
+import { SearchBar } from './components/SearchBar.js';
+import { FilterPanel } from './components/FilterPanel.js';
+import { SortSelect } from './components/SortSelect.js';
+import { AlbumList } from './components/AlbumList.js';
+import type { SearchAlbumsResult } from './types.js';
+import './App.css';
+
+// The detail view is a separate route and a separate chunk: the search page
+// (what most sessions spend their time on) stays lean, and the review form and
+// tracklist code is only downloaded when someone opens an album.
+const AlbumDetail = lazy(() => import('./components/AlbumDetail.js'));
+
+const PAGE_SIZE = 20;
+
+function SearchPage() {
+ const state = useSearchState();
+ const identity = getIdentity();
+ // The query variable is debounced; the input itself is updated immediately
+ // so typing stays responsive.
+ const [liveQuery, setLiveQuery] = useState(state.query);
+ const debouncedQuery = useDebounce(liveQuery, 300);
+
+ const filters = useMemo(
+ () => ({
+ genres: state.genres.length ? state.genres.map(Number) : undefined,
+ decades: state.decades.length ? state.decades : undefined,
+ }),
+ [state.genres, state.decades],
+ );
+
+ const { data, loading, fetchMore, refetch, networkStatus } =
+ useQuery(SEARCH_ALBUMS, {
+ variables: {
+ query: debouncedQuery || undefined,
+ filters: filters.genres || filters.decades ? filters : undefined,
+ sort: state.sort,
+ first: PAGE_SIZE,
+ userId: identity.userId,
+ },
+ notifyOnNetworkStatusChange: true,
+ // Apollo's cache is the "caching" the lecture asks us to think about:
+ // going back to a previous search reuses the stored page instead of
+ // refetching it.
+ fetchPolicy: 'cache-first',
+ });
+
+ const [toggleCrate] = useMutation(TOGGLE_CRATE);
+
+ const result = data?.searchAlbums;
+ const albums = result?.edges.map((e) => e.node) ?? [];
+
+ const onLoadMore = useCallback(() => {
+ if (!result?.pageInfo.hasNextPage || networkStatus === NetworkStatus.fetchMore)
+ return;
+ fetchMore({
+ variables: {
+ query: debouncedQuery || undefined,
+ filters: filters.genres || filters.decades ? filters : undefined,
+ sort: state.sort,
+ first: PAGE_SIZE,
+ after: result.pageInfo.endCursor,
+ userId: identity.userId,
+ },
+ });
+ }, [
+ result,
+ fetchMore,
+ debouncedQuery,
+ filters,
+ state.sort,
+ identity.userId,
+ networkStatus,
+ ]);
+
+ const onToggleCrate = useCallback(
+ async (albumId: string) => {
+ await toggleCrate({
+ variables: {
+ albumId,
+ userId: identity.userId,
+ displayName: identity.displayName,
+ },
+ });
+ await refetch();
+ },
+ [toggleCrate, identity, refetch],
+ );
+
+ return (
+
+
{
+ setLiveQuery(q);
+ state.setQuery(q);
+ }}
+ />
+
+
+ );
+}
+
+export default function App() {
+ return (
+ <>
+
+ Skip to results
+
+
+
+ Loading…
}>
+
+ } />
+ } />
+
+
+
+
+ >
+ );
+}
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
new file mode 100644
index 0000000..fa43374
--- /dev/null
+++ b/client/src/api/client.ts
@@ -0,0 +1,27 @@
+import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
+
+const uri = import.meta.env.VITE_GRAPHQL_URL ?? '/graphql';
+
+export const client = new ApolloClient({
+ link: new HttpLink({ uri }),
+ cache: new InMemoryCache({
+ typePolicies: {
+ Query: {
+ fields: {
+ // Cursor pagination accumulates edges across "load more" fetches
+ // instead of replacing the list, which is what infinite scroll needs.
+ searchAlbums: {
+ keyArgs: ['query', 'filters', 'sort'],
+ merge(existing, incoming, { args }) {
+ if (!args?.after) return incoming;
+ return {
+ ...incoming,
+ edges: [...(existing?.edges ?? []), ...incoming.edges],
+ };
+ },
+ },
+ },
+ },
+ },
+ }),
+});
diff --git a/client/src/api/queries.ts b/client/src/api/queries.ts
new file mode 100644
index 0000000..9f37553
--- /dev/null
+++ b/client/src/api/queries.ts
@@ -0,0 +1,142 @@
+import { gql } from '@apollo/client';
+
+/**
+ * One query does the whole search view: page of albums + total count + facet
+ * counts. Fetching facets in the same request keeps the filter panel in sync
+ * with the result set and avoids a second round-trip (the lecture's
+ * over/under-fetching point).
+ */
+export const SEARCH_ALBUMS = gql`
+ query SearchAlbums(
+ $query: String
+ $filters: AlbumFilters
+ $sort: AlbumSort
+ $first: Int
+ $after: String
+ $userId: ID!
+ ) {
+ searchAlbums(
+ query: $query
+ filters: $filters
+ sort: $sort
+ first: $first
+ after: $after
+ ) {
+ totalCount
+ pageInfo {
+ endCursor
+ hasNextPage
+ }
+ genreFacets {
+ genre {
+ id
+ name
+ }
+ count
+ }
+ decadeFacets {
+ decade
+ count
+ }
+ edges {
+ cursor
+ node {
+ id
+ title
+ year
+ coverUrl
+ averageRating
+ reviewCount
+ artist {
+ id
+ name
+ }
+ genres {
+ id
+ name
+ }
+ inCrate(userId: $userId)
+ }
+ }
+ }
+ }
+`;
+
+export const ALBUM_DETAIL = gql`
+ query AlbumDetail($id: ID!, $userId: ID!) {
+ album(id: $id) {
+ id
+ title
+ year
+ coverUrl
+ averageRating
+ reviewCount
+ artist {
+ id
+ name
+ country
+ formedYear
+ }
+ genres {
+ id
+ name
+ }
+ tracks {
+ id
+ number
+ title
+ durationSeconds
+ }
+ reviews {
+ id
+ rating
+ text
+ createdAt
+ user {
+ id
+ displayName
+ }
+ }
+ myReview(userId: $userId) {
+ id
+ rating
+ text
+ }
+ inCrate(userId: $userId)
+ }
+ }
+`;
+
+export const UPSERT_REVIEW = gql`
+ mutation UpsertReview(
+ $albumId: ID!
+ $rating: Int!
+ $text: String
+ $userId: ID!
+ $displayName: String!
+ ) {
+ upsertReview(
+ albumId: $albumId
+ rating: $rating
+ text: $text
+ userId: $userId
+ displayName: $displayName
+ ) {
+ id
+ rating
+ text
+ }
+ }
+`;
+
+export const DELETE_REVIEW = gql`
+ mutation DeleteReview($albumId: ID!, $userId: ID!) {
+ deleteReview(albumId: $albumId, userId: $userId)
+ }
+`;
+
+export const TOGGLE_CRATE = gql`
+ mutation ToggleCrate($albumId: ID!, $userId: ID!, $displayName: String!) {
+ toggleCrate(albumId: $albumId, userId: $userId, displayName: $displayName)
+ }
+`;
diff --git a/client/src/components/AlbumCard.tsx b/client/src/components/AlbumCard.tsx
new file mode 100644
index 0000000..5209322
--- /dev/null
+++ b/client/src/components/AlbumCard.tsx
@@ -0,0 +1,53 @@
+import { Link } from 'react-router-dom';
+import type { AlbumSummary } from '../types.js';
+import { StarRating } from './StarRating.js';
+
+type Props = {
+ album: AlbumSummary;
+ onToggleCrate: (id: string) => void;
+};
+
+export function AlbumCard({ album, onToggleCrate }: Props) {
+ return (
+
+
+ {album.coverUrl ? (
+
+ ) : (
+
{album.title.slice(0, 1)}
+ )}
+
+
+
+ {album.title}
+
+
{album.artist.name}
+
+ {album.year ?? 'Year unknown'}
+ {album.genres.length > 0 && (
+ <> · {album.genres.map((g) => g.name).join(', ')}>
+ )}
+
+
+
+
+ {album.reviewCount === 0
+ ? 'No reviews yet'
+ : `${album.reviewCount} review${album.reviewCount === 1 ? '' : 's'}`}
+
+
+
+ onToggleCrate(album.id)}
+ aria-pressed={album.inCrate}
+ >
+ {album.inCrate ? 'In your crate' : 'Add to crate'}
+
+
+ );
+}
diff --git a/client/src/components/AlbumDetail.tsx b/client/src/components/AlbumDetail.tsx
new file mode 100644
index 0000000..b7da867
--- /dev/null
+++ b/client/src/components/AlbumDetail.tsx
@@ -0,0 +1,167 @@
+import { useQuery, useMutation } from '@apollo/client/react';
+import { Link, useParams } from 'react-router-dom';
+import {
+ ALBUM_DETAIL,
+ DELETE_REVIEW,
+ TOGGLE_CRATE,
+ UPSERT_REVIEW,
+} from '../api/queries.js';
+import type { AlbumDetail as AlbumDetailData } from '../types.js';
+import { getIdentity } from '../hooks/useUserId.js';
+import { StarRating } from './StarRating.js';
+import { ReviewForm } from './ReviewForm.js';
+
+function formatDuration(seconds: number | null): string {
+ if (seconds == null) return '—';
+ const m = Math.floor(seconds / 60);
+ const s = seconds % 60;
+ return `${m}:${String(s).padStart(2, '0')}`;
+}
+
+/**
+ * Detail view for one album: tracklist, community reviews and the current
+ * user's own review + crate toggle. The query nests artist, tracks, genres and
+ * reviews in a single GraphQL request — the point of GraphQL compared to the
+ * REST N+1 pattern from the lecture.
+ *
+ * Exported as default so App.tsx can lazy-load it as a separate chunk.
+ */
+export default function AlbumDetail() {
+ const { id } = useParams<{ id: string }>();
+ const identity = getIdentity();
+
+ const { data, loading, error, refetch } = useQuery(ALBUM_DETAIL, {
+ variables: { id, userId: identity.userId },
+ skip: !id,
+ });
+
+ const [upsertReview] = useMutation(UPSERT_REVIEW);
+ const [deleteReview] = useMutation(DELETE_REVIEW);
+ const [toggleCrate] = useMutation(TOGGLE_CRATE);
+
+ if (loading) return Loading album…
;
+ if (error) return Could not load this album.
;
+ const album = data?.album;
+ if (!album) return Album not found.
;
+
+ return (
+
+
+ ← Back to results
+
+
+ {album.title}
+
+ {album.artist.name}
+ {album.artist.country && <> · {album.artist.country}>}
+ {album.year != null && <> · {album.year}>}
+
+
+
+
+ {album.reviewCount === 0
+ ? 'No reviews yet'
+ : ` ${album.averageRating?.toFixed(1)} from ${album.reviewCount} review${album.reviewCount === 1 ? '' : 's'}`}
+
+
+
+ {album.genres.map((g: { name: string }) => g.name).join(' · ')}
+
+ {
+ await toggleCrate({
+ variables: {
+ albumId: album.id,
+ userId: identity.userId,
+ displayName: identity.displayName,
+ },
+ });
+ await refetch();
+ }}
+ >
+ {album.inCrate ? 'In your crate' : 'Add to crate'}
+
+
+
+
+ Tracklist
+
+ {album.tracks.map(
+ (t: {
+ id: string;
+ number: number;
+ title: string;
+ durationSeconds: number | null;
+ }) => (
+
+ {t.title}
+
+ {formatDuration(t.durationSeconds)}
+
+
+ ),
+ )}
+
+
+
+
+ Reviews
+ {album.reviews.length === 0 && (
+ No reviews yet. Be the first.
+ )}
+
+
+ {
+ await upsertReview({
+ variables: {
+ albumId: album.id,
+ rating,
+ text,
+ userId: identity.userId,
+ displayName: identity.displayName,
+ },
+ });
+ await refetch();
+ }}
+ onDelete={async () => {
+ await deleteReview({
+ variables: { albumId: album.id, userId: identity.userId },
+ });
+ await refetch();
+ }}
+ />
+
+
+ );
+}
diff --git a/client/src/components/AlbumList.tsx b/client/src/components/AlbumList.tsx
new file mode 100644
index 0000000..f2dc5e2
--- /dev/null
+++ b/client/src/components/AlbumList.tsx
@@ -0,0 +1,77 @@
+import { useEffect, useRef } from 'react';
+import type { AlbumSummary } from '../types.js';
+import { AlbumCard } from './AlbumCard.js';
+
+type Props = {
+ albums: AlbumSummary[];
+ hasNextPage: boolean;
+ loading: boolean;
+ totalCount: number;
+ query: string;
+ onLoadMore: () => void;
+ onToggleCrate: (id: string) => void;
+};
+
+/**
+ * Result list with "load more" plus an IntersectionObserver so the next page
+ * is fetched when the sentinel scrolls into view. A button is kept alongside
+ * the observer because infinite scroll alone is a keyboard trap (the brief
+ * requires universal utforming).
+ */
+export function AlbumList({
+ albums,
+ hasNextPage,
+ loading,
+ totalCount,
+ query,
+ onLoadMore,
+ onToggleCrate,
+}: Props) {
+ const sentinel = useRef(null);
+
+ useEffect(() => {
+ const el = sentinel.current;
+ if (!el || !hasNextPage || loading) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((e) => e.isIntersecting)) onLoadMore();
+ },
+ { rootMargin: '400px' },
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [hasNextPage, loading, onLoadMore]);
+
+ return (
+
+
+ {loading && albums.length === 0
+ ? 'Searching…'
+ : `${totalCount} album${totalCount === 1 ? '' : 's'}${query ? ` matching “${query}”` : ''}`}
+
+
+ {albums.length === 0 && !loading && (
+
+ No albums matched. Try a shorter search word or clear a filter — the counts in
+ the filter panel show what each choice would return.
+
+ )}
+
+
+ {albums.map((a) => (
+
+
+
+ ))}
+
+
+ {hasNextPage && (
+
+
+ {loading ? 'Loading…' : 'Load more'}
+
+
+ )}
+
+ );
+}
diff --git a/client/src/components/FilterPanel.tsx b/client/src/components/FilterPanel.tsx
new file mode 100644
index 0000000..159a8fd
--- /dev/null
+++ b/client/src/components/FilterPanel.tsx
@@ -0,0 +1,77 @@
+type GenreFacet = { genre: { id: string; name: string }; count: number };
+type DecadeFacet = { decade: number; count: number };
+
+type Props = {
+ genreFacets: GenreFacet[];
+ decadeFacets: DecadeFacet[];
+ selectedGenres: string[];
+ selectedDecades: number[];
+ onToggleGenre: (id: string) => void;
+ onToggleDecade: (decade: number) => void;
+};
+
+/**
+ * Faceted filter panel.
+ *
+ * Two deliberate UX choices from the lecture's "fallgruver" list:
+ * - categories that would match zero albums are hidden (never show a filter
+ * that leads to an empty result set);
+ * - each option shows how many albums it would yield, so the user can see the
+ * effect of a filter before clicking it.
+ */
+export function FilterPanel({
+ genreFacets,
+ decadeFacets,
+ selectedGenres,
+ selectedDecades,
+ onToggleGenre,
+ onToggleDecade,
+}: Props) {
+ return (
+
+ );
+}
diff --git a/client/src/components/ReviewForm.tsx b/client/src/components/ReviewForm.tsx
new file mode 100644
index 0000000..f167587
--- /dev/null
+++ b/client/src/components/ReviewForm.tsx
@@ -0,0 +1,88 @@
+import { useState } from 'react';
+import { StarRating } from './StarRating.js';
+
+type Props = {
+ albumId: string;
+ initialRating: number | null;
+ initialText: string | null;
+ onSubmit: (rating: number, text: string) => Promise;
+ onDelete: () => Promise;
+};
+
+/**
+ * Review form. One review per user per album: submitting again replaces the
+ * previous one (the mutation is an upsert). A delete is offered when the user
+ * already has a review.
+ */
+export function ReviewForm({
+ albumId,
+ initialRating,
+ initialText,
+ onSubmit,
+ onDelete,
+}: Props) {
+ const [rating, setRating] = useState(initialRating);
+ const [text, setText] = useState(initialText ?? '');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ const submit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (rating == null) {
+ setError('Choose a rating between 1 and 5.');
+ return;
+ }
+ setBusy(true);
+ setError(null);
+ try {
+ await onSubmit(rating, text.trim());
+ } catch {
+ setError('Could not save your review. Try again.');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/client/src/components/SearchBar.tsx b/client/src/components/SearchBar.tsx
new file mode 100644
index 0000000..3bc186f
--- /dev/null
+++ b/client/src/components/SearchBar.tsx
@@ -0,0 +1,42 @@
+import { useState } from 'react';
+
+type Props = {
+ initialQuery: string;
+ onSubmit: (query: string) => void;
+};
+
+/**
+ * Search box with both "submit on Enter" and an explicit button, as the
+ * lecture describes. The parent debounces the submitted value before it
+ * becomes the GraphQL query, so we don't fire a request per keystroke.
+ */
+export function SearchBar({ initialQuery, onSubmit }: Props) {
+ const [value, setValue] = useState(initialQuery);
+
+ return (
+ {
+ e.preventDefault();
+ onSubmit(value);
+ }}
+ >
+
+ Search albums or artists
+
+ setValue(e.target.value)}
+ autoComplete="off"
+ enterKeyHint="search"
+ />
+ Search
+
+ );
+}
diff --git a/client/src/components/SortSelect.tsx b/client/src/components/SortSelect.tsx
new file mode 100644
index 0000000..7ab3cd6
--- /dev/null
+++ b/client/src/components/SortSelect.tsx
@@ -0,0 +1,39 @@
+import type { AlbumSort } from '../types.js';
+
+const OPTIONS: { value: AlbumSort; label: string }[] = [
+ { value: 'RELEVANCE', label: 'Relevance' },
+ { value: 'TITLE_ASC', label: 'Title A–Z' },
+ { value: 'TITLE_DESC', label: 'Title Z–A' },
+ { value: 'YEAR_DESC', label: 'Newest first' },
+ { value: 'YEAR_ASC', label: 'Oldest first' },
+ { value: 'RATING_DESC', label: 'Highest rated' },
+ { value: 'RATING_ASC', label: 'Lowest rated' },
+];
+
+type Props = {
+ value: AlbumSort;
+ onChange: (sort: AlbumSort) => void;
+};
+
+/**
+ * Sort control. Sorting is applied in SQL over the whole result set, so
+ * switching sort re-queries the server rather than rearranging the loaded page.
+ */
+export function SortSelect({ value, onChange }: Props) {
+ return (
+
+ Sort by
+ onChange(e.target.value as AlbumSort)}
+ >
+ {OPTIONS.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ );
+}
diff --git a/client/src/components/StarRating.tsx b/client/src/components/StarRating.tsx
new file mode 100644
index 0000000..520fc64
--- /dev/null
+++ b/client/src/components/StarRating.tsx
@@ -0,0 +1,54 @@
+type Props = {
+ value: number | null;
+ onChange?: (rating: number) => void;
+ /** Accessible label, e.g. "Rate Abbey Road". */
+ label: string;
+};
+
+/**
+ * Star rating. As a read-only display it renders text + visual stars; as an
+ * interactive control it renders a radio group so keyboard and screen-reader
+ * users can set a rating the same way as mouse users (WCAG "inputs use
+ * appropriate roles").
+ */
+export function StarRating({ value, onChange, label }: Props) {
+ const stars = [1, 2, 3, 4, 5];
+
+ if (!onChange) {
+ return (
+
+ {stars.map((s) => (
+
+ ★
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {label}
+ {stars.map((s) => (
+
+ onChange(s)}
+ aria-label={`${s} of 5`}
+ />
+ ★
+
+ ))}
+
+ );
+}
diff --git a/client/src/components/components.test.tsx b/client/src/components/components.test.tsx
new file mode 100644
index 0000000..fd507b4
--- /dev/null
+++ b/client/src/components/components.test.tsx
@@ -0,0 +1,120 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { StarRating } from './StarRating.js';
+import { SearchBar } from './SearchBar.js';
+import { FilterPanel } from './FilterPanel.js';
+import { AlbumCard } from './AlbumCard.js';
+import { MemoryRouter } from 'react-router-dom';
+
+describe('StarRating', () => {
+ it('exposes the value as text for screen readers', () => {
+ render( );
+ expect(screen.getByLabelText('Rating for Abbey Road: 4 of 5')).toBeInTheDocument();
+ });
+
+ it('renders a radio group when interactive', async () => {
+ const onChange = vi.fn();
+ render( );
+ const radios = screen.getAllByRole('radio');
+ expect(radios).toHaveLength(5);
+ await userEvent.click(screen.getByLabelText('3 of 5'));
+ expect(onChange).toHaveBeenCalledWith(3);
+ });
+});
+
+describe('SearchBar', () => {
+ it('supports submitting with Enter', async () => {
+ const onSubmit = vi.fn();
+ render( );
+ await userEvent.type(screen.getByRole('searchbox'), 'beatles{Enter}');
+ expect(onSubmit).toHaveBeenCalledWith('beatles');
+ });
+
+ it('supports submitting with the button', async () => {
+ const onSubmit = vi.fn();
+ render( );
+ await userEvent.type(screen.getByRole('searchbox'), 'miles');
+ await userEvent.click(screen.getByRole('button', { name: 'Search' }));
+ expect(onSubmit).toHaveBeenCalledWith('miles');
+ });
+});
+
+describe('FilterPanel', () => {
+ const facets = [
+ { genre: { id: '1', name: 'Rock' }, count: 12 },
+ { genre: { id: '2', name: 'Jazz' }, count: 5 },
+ ];
+
+ it('shows a count next to each option', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Rock')).toBeInTheDocument();
+ expect(screen.getByText('12')).toBeInTheDocument();
+ expect(screen.getByText('1960s')).toBeInTheDocument();
+ expect(screen.getByText('3')).toBeInTheDocument();
+ });
+
+ it('toggles a genre filter', async () => {
+ const onToggleGenre = vi.fn();
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByLabelText(/Rock/));
+ expect(onToggleGenre).toHaveBeenCalledWith('1');
+ });
+});
+
+describe('AlbumCard', () => {
+ const album = {
+ id: '1',
+ title: 'Abbey Road',
+ year: 1969,
+ coverUrl: null,
+ averageRating: 5,
+ reviewCount: 2,
+ artist: { id: '1', name: 'The Beatles' },
+ genres: [{ id: '1', name: 'Rock' }],
+ inCrate: false,
+ };
+
+ it('links to the detail view', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole('link', { name: 'Abbey Road' })).toHaveAttribute(
+ 'href',
+ '/album/1',
+ );
+ });
+
+ it('marks the crate button with aria-pressed', async () => {
+ const onToggleCrate = vi.fn();
+ render(
+
+
+ ,
+ );
+ const btn = screen.getByRole('button', { name: 'In your crate' });
+ expect(btn).toHaveAttribute('aria-pressed', 'true');
+ await userEvent.click(btn);
+ expect(onToggleCrate).toHaveBeenCalledWith('1');
+ });
+});
diff --git a/client/src/hooks/hooks.test.ts b/client/src/hooks/hooks.test.ts
new file mode 100644
index 0000000..81fe1be
--- /dev/null
+++ b/client/src/hooks/hooks.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { useDebounce } from '../hooks/useDebounce';
+import { getIdentity, resetIdentity } from '../hooks/useUserId';
+import { renderHook, act, waitFor } from '@testing-library/react';
+
+describe('useDebounce', () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ it('does not update until the delay has passed', () => {
+ const { result, rerender } = renderHook(({ v }) => useDebounce(v, 300), {
+ initialProps: { v: 'a' },
+ });
+ expect(result.current).toBe('a');
+
+ rerender({ v: 'ab' });
+ expect(result.current).toBe('a');
+
+ act(() => {
+ vi.advanceTimersByTime(299);
+ });
+ expect(result.current).toBe('a');
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(result.current).toBe('ab');
+ });
+
+ it('collapses rapid keystrokes into one update', () => {
+ const { result, rerender } = renderHook(({ v }) => useDebounce(v, 300), {
+ initialProps: { v: '' },
+ });
+ for (const s of ['b', 'be', 'bea', 'beat', 'beatl', 'beatle', 'beatles']) {
+ rerender({ v: s });
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+ }
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+ expect(result.current).toBe('beatles');
+ });
+});
+
+describe('simulated user identity', () => {
+ it('creates and stores an identity on first visit', async () => {
+ resetIdentity();
+ const first = getIdentity();
+ expect(first.userId).toBeTruthy();
+ expect(first.displayName).toBeTruthy();
+ await waitFor(() => {
+ expect(localStorage.getItem('t19.crate.user')).toContain(first.userId);
+ });
+ });
+
+ it('returns the same identity on subsequent visits', () => {
+ resetIdentity();
+ const a = getIdentity();
+ const b = getIdentity();
+ expect(b.userId).toBe(a.userId);
+ expect(b.displayName).toBe(a.displayName);
+ });
+
+ it('recovers from corrupt storage', () => {
+ localStorage.setItem('t19.crate.user', '{not json');
+ const identity = getIdentity();
+ expect(identity.userId).toBeTruthy();
+ });
+});
diff --git a/client/src/hooks/useDebounce.ts b/client/src/hooks/useDebounce.ts
new file mode 100644
index 0000000..19f8e6d
--- /dev/null
+++ b/client/src/hooks/useDebounce.ts
@@ -0,0 +1,17 @@
+import { useEffect, useState } from 'react';
+
+/**
+ * Returns `value` after it has stopped changing for `delay` ms.
+ * This is the debounce the lecture asks for: the search query only fires
+ * once the user pauses typing, instead of once per keystroke.
+ */
+export function useDebounce(value: T, delay = 300): T {
+ const [debounced, setDebounced] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebounced(value), delay);
+ return () => clearTimeout(timer);
+ }, [value, delay]);
+
+ return debounced;
+}
diff --git a/client/src/hooks/useSearchState.ts b/client/src/hooks/useSearchState.ts
new file mode 100644
index 0000000..0ec65d5
--- /dev/null
+++ b/client/src/hooks/useSearchState.ts
@@ -0,0 +1,77 @@
+import { useCallback, useMemo } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import type { AlbumSort } from '../types.js';
+
+/**
+ * Search state lives in the URL. That gives us three things the brief cares
+ * about for free: reload keeps the user's choices, the back button works, and
+ * a result view can be shared as a link. Sorting and filtering are therefore
+ * also clearly "server-side" parameters rather than client-side list tweaks.
+ */
+export type SearchState = {
+ query: string;
+ genres: string[];
+ decades: number[];
+ sort: AlbumSort;
+ setQuery: (q: string) => void;
+ toggleGenre: (id: string) => void;
+ toggleDecade: (decade: number) => void;
+ setSort: (sort: AlbumSort) => void;
+ resetPage: () => void;
+};
+
+export function useSearchState(): SearchState {
+ const [params, setParams] = useSearchParams();
+
+ const query = params.get('q') ?? '';
+ const genres = useMemo(
+ () => (params.get('genres') ?? '').split(',').filter(Boolean),
+ [params],
+ );
+ const decades = useMemo(
+ () =>
+ (params.get('decades') ?? '')
+ .split(',')
+ .filter(Boolean)
+ .map(Number)
+ .filter((n) => Number.isFinite(n)),
+ [params],
+ );
+ const sort = (params.get('sort') as AlbumSort) || 'RELEVANCE';
+
+ const apply = useCallback(
+ (patch: Record) => {
+ setParams((prev) => {
+ const next = new URLSearchParams(prev);
+ // Any change to query/filters/sort invalidates the current page cursor.
+ next.delete('after');
+ for (const [key, value] of Object.entries(patch)) {
+ if (value === null || value === '') next.delete(key);
+ else next.set(key, value);
+ }
+ return next;
+ });
+ },
+ [setParams],
+ );
+
+ return {
+ query,
+ genres,
+ decades,
+ sort,
+ setQuery: (q) => apply({ q: q || null }),
+ toggleGenre: (id) => {
+ const next = genres.includes(id) ? genres.filter((g) => g !== id) : [...genres, id];
+ apply({ genres: next.join(',') || null });
+ },
+ toggleDecade: (decade) => {
+ const next = decades.includes(decade)
+ ? decades.filter((d) => d !== decade)
+ : [...decades, decade];
+ apply({ decades: next.join(',') || null });
+ },
+ setSort: (s) => apply({ sort: s === 'RELEVANCE' ? null : s }),
+ resetPage: () => apply({}),
+ };
+}
diff --git a/client/src/hooks/useUserId.ts b/client/src/hooks/useUserId.ts
new file mode 100644
index 0000000..85aa9e3
--- /dev/null
+++ b/client/src/hooks/useUserId.ts
@@ -0,0 +1,54 @@
+/**
+ * Simulated user identity.
+ *
+ * The lecture explicitly allows this: no real login, just a unique user that
+ * the client stores in localStorage and sends with every mutation. Reviews and
+ * the crate are still persisted server-side against that id, which is the
+ * actual requirement.
+ */
+const STORAGE_KEY = 't19.crate.user';
+
+export type Identity = { userId: string; displayName: string };
+
+function randomId(): string {
+ if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID();
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+ const r = (Math.random() * 16) | 0;
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
+ });
+}
+
+function randomName(): string {
+ const animals = ['Otter', 'Heron', 'Fox', 'Lynx', 'Owl', 'Badger', 'Moose', 'Seal'];
+ const adj = ['Silent', 'Curious', 'Brave', 'Calm', 'Witty', 'Keen'];
+ return `${adj[Math.floor(Math.random() * adj.length)]} ${animals[Math.floor(Math.random() * animals.length)]}`;
+}
+
+/** Returns the stored identity, creating one on first visit. */
+export function getIdentity(): Identity {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw) as Identity;
+ if (parsed.userId && parsed.displayName) return parsed;
+ }
+ } catch {
+ // Corrupt storage: fall through and start fresh.
+ }
+ const identity: Identity = { userId: randomId(), displayName: randomName() };
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(identity));
+ } catch {
+ // Storage full or blocked: the identity still works for this session.
+ }
+ return identity;
+}
+
+/** Test helper: reset the identity so tests start from a clean slate. */
+export function resetIdentity(): void {
+ try {
+ localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ /* ignore */
+ }
+}
diff --git a/client/src/index.css b/client/src/index.css
new file mode 100644
index 0000000..ca99a2a
--- /dev/null
+++ b/client/src/index.css
@@ -0,0 +1,10 @@
+/* Base element styles live in App.css; this file is imported first and keeps
+ body defaults minimal so components can opt into layout. */
+html {
+ scroll-behavior: smooth;
+}
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
new file mode 100644
index 0000000..0d950fc
--- /dev/null
+++ b/client/src/main.tsx
@@ -0,0 +1,17 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { ApolloProvider } from '@apollo/client/react';
+import { BrowserRouter } from 'react-router-dom';
+import { client } from './api/client.js';
+import App from './App.js';
+import './index.css';
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+ ,
+);
diff --git a/client/src/test/handlers.ts b/client/src/test/handlers.ts
new file mode 100644
index 0000000..da30510
--- /dev/null
+++ b/client/src/test/handlers.ts
@@ -0,0 +1,81 @@
+/**
+ * MSW handlers for GraphQL requests in tests.
+ *
+ * Mirrors the shape of the real schema so components can be tested in
+ * isolation from the database. Unhandled operations fail the test, which is
+ * how P1 already works and how we catch accidental over-fetching.
+ */
+import { graphql } from 'msw';
+
+// MSW's GraphQL resolver types are strict about the exact payload shape; we
+// return fixtures that match our schema, so a cast keeps the fixture readable.
+const asResolver = (fn: T) => fn as never;
+
+export const sampleAlbum = {
+ id: '1',
+ title: 'Abbey Road',
+ year: 1969,
+ coverUrl: null,
+ averageRating: 5,
+ reviewCount: 1,
+ artist: { id: '1', name: 'The Beatles', country: 'GB', formedYear: 1960 },
+ genres: [{ id: '1', name: 'Rock' }],
+ tracks: [{ id: '1', number: 1, title: 'Come Together', durationSeconds: 259 }],
+ reviews: [
+ {
+ id: '10',
+ rating: 5,
+ text: 'The medley on side two is unbeatable.',
+ createdAt: '2026-09-22T12:00:00.000Z',
+ user: { id: 'u1', displayName: 'Demo Listener' },
+ },
+ ],
+ myReview: null,
+ inCrate: false,
+};
+
+export const handlers = [
+ graphql.query(
+ 'SearchAlbums',
+ asResolver(({ variables }: { variables: Record }) => {
+ return {
+ data: {
+ searchAlbums: {
+ totalCount: 1,
+ pageInfo: { endCursor: null, hasNextPage: false },
+ genreFacets: [{ genre: { id: '1', name: 'Rock' }, count: 1 }],
+ decadeFacets: [{ decade: 1960, count: 1 }],
+ edges: [
+ {
+ cursor: 'c1',
+ node: { ...sampleAlbum, inCrate: Boolean(variables?.userId) },
+ },
+ ],
+ },
+ },
+ };
+ }),
+ ),
+
+ graphql.query(
+ 'AlbumDetail',
+ asResolver(() => ({ data: { album: sampleAlbum } })),
+ ),
+
+ graphql.mutation(
+ 'UpsertReview',
+ asResolver(() => ({
+ data: { upsertReview: { id: '10', rating: 5, text: 'Nice.' } },
+ })),
+ ),
+
+ graphql.mutation(
+ 'DeleteReview',
+ asResolver(() => ({ data: { deleteReview: true } })),
+ ),
+
+ graphql.mutation(
+ 'ToggleCrate',
+ asResolver(() => ({ data: { toggleCrate: true } })),
+ ),
+];
diff --git a/client/src/test/setup.ts b/client/src/test/setup.ts
new file mode 100644
index 0000000..254696d
--- /dev/null
+++ b/client/src/test/setup.ts
@@ -0,0 +1,14 @@
+import '@testing-library/jest-dom/vitest';
+import { cleanup } from '@testing-library/react';
+import { afterEach, beforeEach } from 'vitest';
+import { resetIdentity } from '../hooks/useUserId.js';
+
+beforeEach(() => {
+ resetIdentity();
+});
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+ sessionStorage.clear();
+});
diff --git a/client/src/types.ts b/client/src/types.ts
new file mode 100644
index 0000000..1a13d78
--- /dev/null
+++ b/client/src/types.ts
@@ -0,0 +1,63 @@
+export type AlbumSort =
+ | 'RELEVANCE'
+ | 'TITLE_ASC'
+ | 'TITLE_DESC'
+ | 'YEAR_DESC'
+ | 'YEAR_ASC'
+ | 'RATING_DESC'
+ | 'RATING_ASC';
+
+export type AlbumSummary = {
+ id: string;
+ title: string;
+ year: number | null;
+ coverUrl: string | null;
+ averageRating: number | null;
+ reviewCount: number;
+ artist: { id: string; name: string };
+ genres: { id: string; name: string }[];
+ inCrate: boolean;
+};
+
+export type SearchAlbumsResult = {
+ searchAlbums: {
+ totalCount: number;
+ pageInfo: { endCursor: string | null; hasNextPage: boolean };
+ genreFacets: { genre: { id: string; name: string }; count: number }[];
+ decadeFacets: { decade: number; count: number }[];
+ edges: { cursor: string; node: AlbumSummary }[];
+ };
+};
+
+export type AlbumDetail = {
+ album: {
+ id: string;
+ title: string;
+ year: number | null;
+ coverUrl: string | null;
+ averageRating: number | null;
+ reviewCount: number;
+ artist: {
+ id: string;
+ name: string;
+ country: string | null;
+ formedYear: number | null;
+ };
+ genres: { id: string; name: string }[];
+ tracks: {
+ id: string;
+ number: number;
+ title: string;
+ durationSeconds: number | null;
+ }[];
+ reviews: {
+ id: string;
+ rating: number;
+ text: string | null;
+ createdAt: string;
+ user: { id: string; displayName: string };
+ }[];
+ myReview: { id: string; rating: number; text: string | null } | null;
+ inCrate: boolean;
+ } | null;
+};
diff --git a/client/tsconfig.json b/client/tsconfig.json
new file mode 100644
index 0000000..5d390b4
--- /dev/null
+++ b/client/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "noEmit": true,
+ "types": ["vite/client", "vitest/globals"]
+ },
+ "include": ["src/**/*", "vite.config.ts", "vitest.config.ts"]
+}
diff --git a/client/tsconfig.tsbuildinfo b/client/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..de0bde7
--- /dev/null
+++ b/client/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/api/client.ts","./src/api/queries.ts","./src/components/AlbumCard.tsx","./src/components/AlbumDetail.tsx","./src/components/AlbumList.tsx","./src/components/FilterPanel.tsx","./src/components/ReviewForm.tsx","./src/components/SearchBar.tsx","./src/components/SortSelect.tsx","./src/components/StarRating.tsx","./src/components/components.test.tsx","./src/hooks/hooks.test.ts","./src/hooks/useDebounce.ts","./src/hooks/useSearchState.ts","./src/hooks/useUserId.ts","./src/test/handlers.ts","./src/test/setup.ts","./vite.config.ts","./vitest.config.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/client/vite.config.ts b/client/vite.config.ts
new file mode 100644
index 0000000..c643ea9
--- /dev/null
+++ b/client/vite.config.ts
@@ -0,0 +1,23 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ // Served from /project2 on the group VM (see the course VM guide).
+ base: '/project2/',
+ server: {
+ port: 5173,
+ proxy: {
+ '/graphql': {
+ target: 'http://localhost:3001',
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ sourcemap: false,
+ // Keep the bundle honest about the sustainability requirement: a budget
+ // that fails the build if the JS payload creeps up.
+ chunkSizeWarningLimit: 250,
+ },
+});
diff --git a/client/vitest.config.ts b/client/vitest.config.ts
new file mode 100644
index 0000000..4f6d395
--- /dev/null
+++ b/client/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/test/setup.ts'],
+ include: ['src/**/*.test.{ts,tsx}'],
+ },
+});
diff --git a/docs/AI-USAGE.md b/docs/AI-USAGE.md
new file mode 100644
index 0000000..2e71777
--- /dev/null
+++ b/docs/AI-USAGE.md
@@ -0,0 +1,95 @@
+# AI usage log
+
+IT2810 requires documentation of how AI tools were used and that the group
+exercises its own control over the development. This file is that log. Entries
+are appended as work happens.
+
+## Policy
+
+1. AI output is never merged unreviewed. Every AI-assisted change goes through
+ the same branch + PR + peer review as anything else (see `CODEOWNERS`).
+2. Prompts that produced substantial code or design decisions are recorded
+ here, together with what we accepted, changed or rejected.
+3. The person who opens the PR is responsible for understanding and being able
+ to explain every line in it.
+
+## Tools in use
+
+| Tool | Used for |
+| ----------------------- | --------------------------------------------------------------------------------------------------------------- |
+| opencode (CLI agent) | Initial monorepo scaffold, schema design, migration SQL, resolver and component skeletons, documentation drafts |
+| GitHub Copilot (editor) | Autocomplete while writing components and tests |
+| Playwright MCP | Manual exploration / verification of flows while writing E2E tests |
+| Context7 MCP | Looking up current library APIs (Apollo Client, Drizzle, GraphQL Yoga) instead of relying on training data |
+
+## Log
+
+### 2026-09-22 — project plan and stack choice (meeting prep)
+
+- **Prompt/context:** course brief + lecture slides + Project 1 README, asked
+ for a recommendation on what to build and which stack.
+- **AI produced:** three domain proposals, a PostgreSQL + GraphQL Yoga stack,
+ a warning that a BaaS would violate the VM + custom-GraphQL rules, and a
+ milestone plan.
+- **Accepted:** domain (community album catalog), stack, BaaS warning,
+ milestone breakdown, the simulated-user approach (later confirmed by the
+ lecture slides).
+- **Rejected/changed:** the AI first assumed Project 2 had one interim
+ submission; corrected to four after reading the delivery schedule. The
+ pagination example used OFFSET-style cursors in one draft — replaced with a
+ proper keyset design before implementation.
+
+### 2026-09-22 — monorepo scaffold and server implementation
+
+- **Prompt/context:** "configure the entire tech stack so the others can start
+ working", with the lecture constraints (cursor paging, no BaaS, N+1,
+ simulated users).
+- **AI produced:** pnpm workspace, GraphQL SDL, Drizzle schema + migration SQL
+ with the `search_vector` trigger, DataLoader layer, search/filter/sort/cursor
+ SQL builders, GraphQL resolvers, seed + large-scale generator, React client
+ (search page, filter facets, detail view, review form), Vitest + Playwright
+ suites, docs.
+- **Accepted:** structure and API shape. SQL is parameterised throughout; the
+ user query never touches the SQL string (covered by a unit test).
+- **To verify by humans in the PR review:**
+ - keyset cursor behaviour when the sort key is NULL (unrated albums)
+ - facet counts match what the filters would actually return
+ - the `search_vector` trigger updates when an artist is renamed
+ - a11y labels on the star rating and the live result count
+- **Known gaps left as issues:** GraphQL codegen for typed operations, images
+ for covers, country facet, search history, proper CI deploy workflow.
+
+### 2026-09-23 — VM bring-up and smoke testing (caught three real bugs)
+
+- **Prompt/context:** "connect to NTNU-VM and deploy". Two root-level steps
+ needed the human (`sudo`): the bootstrap script (apt/psql/systemd/apache)
+ and `systemctl start|restart crate-api`.
+- **AI produced:** `scripts/crate-vm-bootstrap.sh` + `crate-vm-bootstrap-2.sh`,
+ the deploy runbook in `docs/VM-setup.md`, and a curl-based smoke suite run
+ against the live API.
+- **Bugs the smoke suite caught that unit tests missed** (unit tests only
+ inspect SQL fragments; nothing was executing them against Postgres):
+ 1. `GenreFacet.genre` was serialised as `genreId`/`genreName` while the SDL
+ declares `genre: Genre!` — resolver shape drifted from the schema.
+ 2. Decade/genre/country filters wrote `= ANY(${jsArray}::int[])`. Drizzle
+ expands a JS array into N _scalar_ parameters, so the `::int[]` cast
+ received a bare integer and the query aborted. Fixed with `sql.join` +
+ `IN (…)` and a regression test asserting the template no longer contains
+ `::int[]`.
+ 3. The `album(id:)` query selected no review aggregate, so
+ `reviewCount: Int!` / `averageRating` resolved to `null` and the query
+ failed. Now the same `LEFT JOIN LATERAL` the search uses.
+- **Also fixed:** `SEARCH_ALBUMS` referenced `$userId` without declaring it in
+ the operation signature (would have failed validation in the browser).
+- **Lesson for the team:** unit tests on SQL _builders_ are necessary but not
+ sufficient. Always hit the running API with a curl smoke suite before
+ calling a backend change done.
+- **Verified after the fixes:** case-insensitive search, genre + decade facets
+ with counts, cursor pagination (`hasNextPage`, stable cursors), server-side
+ `RATING_DESC` over the aggregate, `upsertReview` / `toggleCrate` round-trip
+ (persistence), nested `album → artist/tracks/genres/reviews` in one request,
+ Apache `/graphql` proxy 200, client index 200.
+
+---
+
+_Append new entries above this line, newest first._
diff --git a/docs/VM-setup.md b/docs/VM-setup.md
new file mode 100644
index 0000000..fb6db1b
--- /dev/null
+++ b/docs/VM-setup.md
@@ -0,0 +1,138 @@
+# VM setup (it2810-19.idi.ntnu.no)
+
+The database and the GraphQL backend run **directly on the VM** — no Docker,
+no external service. This is the runbook from a clean Ubuntu install to a
+running stack.
+
+## 0. Access
+
+```bash
+ssh NTNU-VM # alias in ~/.ssh/config, requires NTNU VPN
+```
+
+## 1. System packages
+
+```bash
+sudo apt update
+sudo apt install -y postgresql apache2 curl git
+```
+
+## 2. Node.js 24 (per user, via nvm)
+
+```bash
+touch ~/.bash_profile
+curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
+source ~/.bash_profile
+nvm install 24
+corepack enable && corepack prepare pnpm@10.33.0 --activate
+```
+
+## 3. PostgreSQL (database only reachable from the VM)
+
+```bash
+sudo -u postgres psql <<'SQL'
+CREATE USER t19 WITH PASSWORD '';
+CREATE DATABASE crate OWNER t19;
+SQL
+```
+
+Local-only by default (Ubuntu's `pg_hba.conf` uses `peer`/`md5` on
+`127.0.0.1`). The API is the only public surface; the database is not exposed.
+
+Connection string used by the API:
+
+```
+DATABASE_URL=postgres://t19:@127.0.0.1:5432/crate
+```
+
+## 4. Deploy the API
+
+```bash
+cd ~
+git clone git@git.ntnu.no:IT2810-H26/T19-Project-2.git
+cd T19-Project-2
+pnpm install
+cp .env.example .env # set DATABASE_URL and PORT=3001
+pnpm --filter @t19/server build
+pnpm db:migrate
+pnpm db:seed # optional: pnpm db:seed:large
+```
+
+### systemd unit (keeps the API alive across reboots)
+
+`/etc/systemd/system/crate-api.service`:
+
+```ini
+[Unit]
+Description=Crate GraphQL API
+After=network.target postgresql.service
+
+[Service]
+Type=simple
+User=erikhfj
+WorkingDirectory=/home/erikhfj/T19-Project-2/server
+EnvironmentFile=/home/erikhfj/T19-Project-2/.env
+ExecStart=/home/erikhfj/.nvm/versions/node/v24.13.0/bin/node dist/index.js
+Restart=on-failure
+RestartSec=3
+
+[Install]
+WantedBy=multi-user.target
+```
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable --now crate-api
+sudo systemctl status crate-api
+curl -s http://localhost:3001/graphql -H 'content-type: application/json' \
+ -d '{"query":"{ genres { name } }"}'
+```
+
+## 5. Deploy the client to Apache
+
+```bash
+pnpm --filter @t19/client build # outputs client/dist with base /project2/
+sudo rm -rf /var/www/html/project2
+sudo mkdir -p /var/www/html/project2
+sudo cp -r client/dist/* /var/www/html/project2/
+```
+
+Live URL:
+
+The client calls `/graphql` same-origin in production (Apache proxies it) or
+`VITE_GRAPHQL_URL` if set at build time.
+
+### Apache reverse proxy for the API (recommended)
+
+`/etc/apache2/sites-available/000-default.conf` (inside the relevant
+``):
+
+```apache
+ProxyPreserveHost On
+ProxyPass /graphql http://127.0.0.1:3001/graphql
+ProxyPassReverse /graphql http://127.0.0.1:3001/graphql
+```
+
+```bash
+sudo a2enmod proxy proxy_http
+sudo systemctl restart apache2
+```
+
+## 6. CI/CD (self-hosted runner)
+
+A GitHub Actions self-hosted runner is installed on the VM (same setup as
+Project 1 — see `T19-Project-1/docs/ci-runner.md`). Workflows:
+
+- **PR:** `format:check` → `lint` → `typecheck` → `test` → `build`
+- **push to `main`:** the above, then deploy the client build to
+ `/var/www/html/project2` and `systemctl restart crate-api`
+
+## 7. Useful operations
+
+| Task | Command |
+| ----------------- | ---------------------------------------------------------------------- |
+| API logs | `journalctl -u crate-api -f` |
+| Restart API | `sudo systemctl restart crate-api` |
+| Re-run migrations | `pnpm db:migrate` |
+| Reset catalog | `psql -U t19 -d crate -c 'TRUNCATE album CASCADE'` then `pnpm db:seed` |
+| Explore GraphQL | open `http://localhost:3001/graphql` (GraphiQL) |
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..84b30ab
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,119 @@
+# Architecture decisions
+
+Short write-ups of the choices behind Crate, with the alternatives we
+considered. These are the "viktige valg og løsninger" the README must discuss.
+
+## 1. PostgreSQL + hand-written GraphQL (not MongoDB, not Neo4j, not a BaaS)
+
+**Decision:** PostgreSQL 16 on the VM, GraphQL Yoga + Drizzle, schema-first
+SDL with hand-written resolvers.
+
+**Alternatives considered**
+
+| Option | Why not |
+| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| MongoDB + Apollo Server | Fine, but the brief's hardest requirements are _search_ and _server-side sorting/filtering over the whole set_. Postgres FTS + `tsvector`/GIN and real `ORDER BY`/`WHERE` cover both out of the box; Mongo would need Atlas Search or a lot of manual work |
+| Neo4j + the GraphQL library | Attractive (auto-resolvers, one Cypher query per GraphQL query). Rejected because search relevance ranking and facet counts are more natural in SQL, and we wanted to _implement_ resolvers and solve N+1 explicitly — that is the learning goal of the GraphQL part |
+| Supabase / PocketBase / Hasura | The course requires an **egendefinert** GraphQL backend and a database **set up directly on the VM** (no Docker). A BaaS violates one or both |
+| Prisma | Drizzle keeps the SQL visible and the bundle small; queries here are custom enough that an ORM's query builder adds little |
+
+**Consequences:** we own the schema and the N+1 problem. Solved with
+per-request DataLoaders (see below).
+
+## 2. Cursor (keyset) pagination, not OFFSET
+
+**Decision:** every list is paged with a Relay-style connection: `first` +
+`after` (a base64 cursor of the sort key + id) and a `WHERE (sortkey, id) >
+(cursor)` clause.
+
+**Why:** `OFFSET` degrades linearly as the page number grows and is unstable
+when rows are inserted between page loads. The brief asks for "udefinert stor
+datasamling", so paging has to stay flat-cost on page 1 and page 5000. A
+cursor also makes "load more" idempotent.
+
+**Consequences:** the client cannot jump to an arbitrary page number. For an
+infinite-scroll search UI that is the right trade-off (and the brief allows
+either paging _or_ dynamic loading).
+
+## 3. Sorting and filtering happen in SQL
+
+**Decision:** `sort` and `filters` are arguments to the `searchAlbums` query
+and become `ORDER BY` / `WHERE`. The client never sorts a loaded page.
+
+**Why:** the brief is explicit — sorting and filtering must apply to the whole
+result set, not to what happens to be on the client. Doing it in SQL also
+means paging stays consistent with the sort order (the cursor is expressed in
+the same terms).
+
+## 4. One query returns results _and_ facet counts
+
+**Decision:** `searchAlbums` returns `totalCount`, `genreFacets` and
+`decadeFacets` alongside the page of edges. Each facet's count is computed
+with that dimension removed from the filter (standard faceted-search
+behaviour).
+
+**Why:** the lecture's list of pitfalls includes "don't show filter categories
+that don't exist in the result set" and "show how many hits each filter has".
+Returning the facets with the results keeps the panel in sync and avoids a
+second round-trip.
+
+## 5. Postgres full-text search with a trigger-maintained `search_vector`
+
+**Decision:** `album.search_vector` is a `tsvector` over title (weight A) and
+artist name (weight B), maintained by a `BEFORE INSERT OR UPDATE` trigger, with
+a GIN index. Queries use `websearch_to_tsquery('simple', …)`.
+
+**Why:** `simple` is case-insensitive and does not stem, which matches the
+required "søk på enkeltord, case insensitiv". The GIN index keeps search fast
+as the catalog grows. Putting the vector in the database (rather than filtering
+in JS) is what makes the requirement scalable.
+
+## 6. N+1 handled with per-request DataLoaders
+
+**Decision:** `server/src/loaders/index.ts` creates a fresh set of DataLoaders
+per GraphQL request. Relation fields (`artist`, `tracks`, `genres`, `reviews`,
+`myReview`, `inCrate`) all go through them.
+
+**Why:** GraphQL's remaining weakness (called out in the lecture) is N+1 on
+the _server_ side. A single query for a page of 20 albums with nested data
+would otherwise become 20 × n lookups. Loaders batch each of those into one.
+
+## 7. Simulated users (UUID in localStorage), no login
+
+**Decision:** first visit generates `{userId, displayName}` and stores it in
+`localStorage`. Every mutation sends it; the server does `INSERT … ON CONFLICT
+DO NOTHING` into `user`.
+
+**Why:** the lecture explicitly allows this and it removes a whole class of
+work (passwords, sessions, GDPR) while still meeting the actual requirement —
+user data persisted on the database server and shown back in the UI. One
+review per user per album is enforced by a unique index.
+
+## 8. Apollo Client cache + URL as the search state
+
+**Decision:** Apollo `InMemoryCache` with a `typePolicies` merge that
+concatenates edges on `fetchMore`, and query/filters/sort kept in the URL
+query string.
+
+**Why:** GraphQL has no HTTP caching, so the client cache _is_ the caching
+strategy (as the lecture notes). URL state gives reload-persistence, working
+back/forward and shareable result links for free — and makes it obvious that
+sort/filter are request parameters, not client-side rearrangements.
+
+## 9. Infinite scroll _plus_ a Load-more button
+
+**Decision:** `IntersectionObserver` prefetches the next page, but a real
+ remains.
+
+**Why:** infinite scroll alone is a keyboard trap and hides content from some
+assistive tech. The button is the accessible control; the observer is the
+convenience. The brief demands dynamic loading _and_ universal utforming.
+
+## 10. Plain CSS, no component library
+
+**Decision:** one CSS file with custom properties, `prefers-color-scheme` and
+`prefers-reduced-motion`.
+
+**Why:** smallest possible bundle (bærekraft), full control over focus rings
+and contrast (tilgjengelighet), and no dependency churn. Same convention as
+Project 1.
diff --git a/e2e/package.json b/e2e/package.json
new file mode 100644
index 0000000..dc3bffb
--- /dev/null
+++ b/e2e/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@t19/e2e",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "test": "playwright test",
+ "test:ui": "playwright test --ui",
+ "report": "playwright show-report"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.56.1",
+ "@axe-core/playwright": "^4.11.2",
+ "typescript": "~5.9.3"
+ }
+}
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
new file mode 100644
index 0000000..c768db9
--- /dev/null
+++ b/e2e/playwright.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig, devices } from '@playwright/test';
+
+/**
+ * End-to-end tests run against a real running stack (Vite preview of the
+ * client + the GraphQL server). Set E2E_BASE_URL to point at the VM, e.g.
+ *
+ * E2E_BASE_URL=http://localhost:4173/project2 pnpm test:e2e
+ *
+ * CI starts the server and the preview build before invoking this config.
+ */
+const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:4173/project2';
+
+export default defineConfig({
+ testDir: './tests',
+ timeout: 30_000,
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
+ use: {
+ baseURL,
+ trace: 'on-first-retry',
+ },
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+});
diff --git a/e2e/tests/search.spec.ts b/e2e/tests/search.spec.ts
new file mode 100644
index 0000000..f18ec4a
--- /dev/null
+++ b/e2e/tests/search.spec.ts
@@ -0,0 +1,81 @@
+import { test, expect } from '@playwright/test';
+import AxeBuilder from '@axe-core/playwright';
+
+/**
+ * The full user journey the brief asks E2E to cover: search → filter → sort →
+ * page → open a detail view → write a review → reload and see it persisted.
+ * This is the "lengre sekvens av interaksjoner" requirement.
+ */
+test.describe('Crate search journey', () => {
+ test('search, filter, sort and open a detail view', async ({ page }) => {
+ await page.goto('/');
+
+ // Search by a single term (case-insensitive is checked below).
+ await page.getByRole('searchbox').fill('abbey');
+ await page.getByRole('button', { name: 'Search' }).click();
+
+ await expect(page.getByRole('status')).toContainText(/1 album/);
+ await expect(page.getByRole('link', { name: 'Abbey Road' })).toBeVisible();
+
+ // Case-insensitivity: the same result must come back for ABBEY.
+ await page.getByRole('searchbox').fill('ABBEY');
+ await page.getByRole('button', { name: 'Search' }).click();
+ await expect(page.getByRole('link', { name: 'Abbey Road' })).toBeVisible();
+
+ // Sort changes the request, not just the loaded page.
+ await page.getByLabel('Sort by').selectOption('TITLE_ASC');
+ await expect(page.getByRole('link', { name: 'Abbey Road' })).toBeVisible();
+
+ // Open the detail view.
+ await page.getByRole('link', { name: 'Abbey Road' }).click();
+ await expect(page.getByRole('heading', { name: 'Abbey Road' })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Tracklist' })).toBeVisible();
+ });
+
+ test('filters restrict the result set and show counts', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByRole('status')).toContainText(/albums/);
+
+ const rock = page.getByLabel(/Rock/);
+ if (await rock.count()) {
+ await rock.click();
+ await expect(page.getByRole('status')).toContainText(/album/);
+ }
+ });
+
+ test('a review is persisted on the server', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('searchbox').fill('kind of blue');
+ await page.getByRole('button', { name: 'Search' }).click();
+ await page.getByRole('link', { name: 'Kind of Blue' }).click();
+
+ // Rate 5 stars and submit.
+ await page.getByRole('radio', { name: '5 of 5' }).click();
+ await page.getByLabel(/Comment/).fill('E2E review: modal jazz at its best.');
+ await page.getByRole('button', { name: 'Save review' }).click();
+
+ await expect(page.getByText('E2E review: modal jazz at its best.')).toBeVisible();
+
+ // Reload: the review must still be there (it lives in the database).
+ await page.reload();
+ await expect(page.getByText('E2E review: modal jazz at its best.')).toBeVisible();
+ });
+
+ test('keyboard users can reach and operate the search', async ({ page }) => {
+ await page.goto('/');
+ await page.keyboard.press('Tab'); // skip link
+ await expect(page.getByRole('link', { name: 'Skip to results' })).toBeFocused();
+ await page.keyboard.press('Tab');
+ await expect(page.getByRole('searchbox')).toBeFocused();
+ await page.keyboard.type('beatles{Enter}');
+ await expect(page.getByRole('status')).toContainText(/album/);
+ });
+
+ test('the search page has no automatically detectable accessibility issues', async ({
+ page,
+ }) => {
+ await page.goto('/');
+ const results = await new AxeBuilder({ page }).analyze();
+ expect(results.violations).toEqual([]);
+ });
+});
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..e1403b6
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,47 @@
+import js from '@eslint/js';
+import globals from 'globals';
+import tseslint from 'typescript-eslint';
+import reactHooks from 'eslint-plugin-react-hooks';
+import reactRefresh from 'eslint-plugin-react-refresh';
+import jsxA11y from 'eslint-plugin-jsx-a11y';
+import prettier from 'eslint-config-prettier';
+
+export default tseslint.config(
+ {
+ ignores: [
+ '**/dist/**',
+ '**/coverage/**',
+ '**/node_modules/**',
+ '**/gql/**',
+ '**/test-results/**',
+ '**/playwright-report/**',
+ ],
+ },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ['**/*.{ts,tsx}'],
+ languageOptions: {
+ ecmaVersion: 2023,
+ globals: { ...globals.browser, ...globals.node },
+ },
+ rules: {
+ '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
+ '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
+ },
+ },
+ {
+ files: ['client/**/*.{ts,tsx}'],
+ // eslint-plugin-react-hooks ships both legacy and flat configs; `configs.flat`
+ // is the one ESLint 9's flat config expects (plugins as an object).
+ extends: [
+ reactHooks.configs.flat['recommended-latest'] ??
+ reactHooks.configs.flat.recommended,
+ jsxA11y.flatConfigs.recommended,
+ ],
+ plugins: { 'react-refresh': reactRefresh },
+ rules: {
+ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
+ },
+ },
+ prettier,
+);
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f93706f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "t19-project-2",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "packageManager": "pnpm@10.33.0",
+ "engines": {
+ "node": ">=24"
+ },
+ "scripts": {
+ "dev": "pnpm --parallel --filter @t19/server --filter @t19/client dev",
+ "dev:server": "pnpm --filter @t19/server dev",
+ "dev:client": "pnpm --filter @t19/client dev",
+ "build": "pnpm --filter @t19/server build && pnpm --filter @t19/client build",
+ "lint": "eslint .",
+ "lint:fix": "eslint . --fix",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "typecheck": "pnpm -r typecheck",
+ "test": "pnpm --filter @t19/server test && pnpm --filter @t19/client test",
+ "test:e2e": "pnpm --filter @t19/e2e test",
+ "db:generate": "pnpm --filter @t19/server db:generate",
+ "db:migrate": "pnpm --filter @t19/server db:migrate",
+ "db:seed": "pnpm --filter @t19/server db:seed",
+ "db:seed:large": "pnpm --filter @t19/server db:seed:large",
+ "db:studio": "pnpm --filter @t19/server db:studio"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.5",
+ "eslint": "^9.39.5",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-jsx-a11y": "^6.10.2",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.6",
+ "globals": "^17.12.0",
+ "prettier": "^3.9.6",
+ "typescript": "~5.9.3",
+ "typescript-eslint": "^8.69.0"
+ },
+ "pnpm": {
+ "onlyBuiltDependencies": [
+ "esbuild",
+ "msw"
+ ]
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..14806a4
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,5572 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ devDependencies:
+ '@eslint/js':
+ specifier: ^9.39.5
+ version: 9.39.5
+ eslint:
+ specifier: ^9.39.5
+ version: 9.39.5
+ eslint-config-prettier:
+ specifier: ^10.1.8
+ version: 10.1.8(eslint@9.39.5)
+ eslint-plugin-jsx-a11y:
+ specifier: ^6.10.2
+ version: 6.10.2(eslint@9.39.5)
+ eslint-plugin-react-hooks:
+ specifier: ^7.1.1
+ version: 7.1.1(eslint@9.39.5)
+ eslint-plugin-react-refresh:
+ specifier: ^0.5.6
+ version: 0.5.7(eslint@9.39.5)
+ globals:
+ specifier: ^17.12.0
+ version: 17.12.0
+ prettier:
+ specifier: ^3.9.6
+ version: 3.9.9
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+ typescript-eslint:
+ specifier: ^8.69.0
+ version: 8.70.1(eslint@9.39.5)(typescript@5.9.3)
+
+ client:
+ dependencies:
+ '@apollo/client':
+ specifier: ^4.1.2
+ version: 4.3.1(graphql@16.14.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rxjs@7.8.2)
+ graphql:
+ specifier: ^16.12.0
+ version: 16.14.2
+ react:
+ specifier: ^19.2.8
+ version: 19.3.0
+ react-dom:
+ specifier: ^19.2.8
+ version: 19.3.0(react@19.3.0)
+ react-router-dom:
+ specifier: ^7.9.5
+ version: 7.18.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ devDependencies:
+ '@testing-library/dom':
+ specifier: ^10.4.1
+ version: 10.4.2
+ '@testing-library/jest-dom':
+ specifier: ^7.0.1
+ version: 7.0.1(@testing-library/dom@10.4.2)(vitest@4.1.11(@types/node@24.13.6)(jsdom@29.1.1)(msw@2.15.0(@types/node@24.13.6)(typescript@5.9.3))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15)))
+ '@testing-library/react':
+ specifier: ^16.3.3
+ version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ '@testing-library/user-event':
+ specifier: ^14.6.7
+ version: 14.6.7(@testing-library/dom@10.4.2)
+ '@types/react':
+ specifier: ^19.2.18
+ version: 19.3.0
+ '@types/react-dom':
+ specifier: ^19.2.4
+ version: 19.3.0(@types/react@19.3.0)
+ '@vitejs/plugin-react':
+ specifier: ^6.1.0
+ version: 6.1.1(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15))
+ jsdom:
+ specifier: ^29.1.1
+ version: 29.1.1
+ msw:
+ specifier: ^2.15.0
+ version: 2.15.0(@types/node@24.13.6)(typescript@5.9.3)
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+ vite:
+ specifier: ^8.2.2
+ version: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15)
+ vitest:
+ specifier: ^4.1.11
+ version: 4.1.11(@types/node@24.13.6)(jsdom@29.1.1)(msw@2.15.0(@types/node@24.13.6)(typescript@5.9.3))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15))
+
+ e2e:
+ devDependencies:
+ '@axe-core/playwright':
+ specifier: ^4.11.2
+ version: 4.13.0(playwright-core@1.63.0)
+ '@playwright/test':
+ specifier: ^1.56.1
+ version: 1.63.0
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+
+ server:
+ dependencies:
+ dataloader:
+ specifier: ^2.2.3
+ version: 2.2.3
+ dotenv:
+ specifier: ^17.2.3
+ version: 17.4.2
+ drizzle-orm:
+ specifier: ^0.45.2
+ version: 0.45.3(@types/pg@8.23.1)(pg@8.23.0)
+ graphql:
+ specifier: ^16.12.0
+ version: 16.14.2
+ graphql-yoga:
+ specifier: ^5.18.0
+ version: 5.24.1(graphql@16.14.2)
+ pg:
+ specifier: ^8.16.3
+ version: 8.23.0
+ devDependencies:
+ '@types/node':
+ specifier: ^24.13.3
+ version: 24.13.6
+ '@types/pg':
+ specifier: ^8.15.6
+ version: 8.23.1
+ drizzle-kit:
+ specifier: ^0.31.11
+ version: 0.31.11
+ tsx:
+ specifier: ^4.20.6
+ version: 4.23.15
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+ vitest:
+ specifier: ^4.1.11
+ version: 4.1.11(@types/node@24.13.6)(jsdom@29.1.1)(msw@2.15.0(@types/node@24.13.6)(typescript@5.9.3))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15))
+
+packages:
+
+ '@adobe/css-tools@4.5.0':
+ resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
+
+ '@apollo/client@4.3.1':
+ resolution: {integrity: sha512-MFHzUfHxS+A6AzH+cS4896jHuSTRw5ACUANijz1gyesWZDLsbgZ17oCb/KfRAOUuoUr8AzCaBE+xJPi7R7ZOCA==}
+ peerDependencies:
+ graphql: ^16.0.0 || ^17.0.0
+ graphql-ws: ^5.5.5 || ^6.0.3
+ react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc
+ react-dom: ^17.0.0 || ^18.0.0 || >=19.0.0-rc
+ rxjs: ^7.3.0
+ subscriptions-transport-ws: ^0.9.0 || ^0.11.0
+ peerDependenciesMeta:
+ graphql-ws:
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+ subscriptions-transport-ws:
+ optional: true
+
+ '@asamuzakjp/css-color@5.1.11':
+ resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/dom-selector@7.1.1':
+ resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/generational-cache@1.0.1':
+ resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/nwsapi@2.3.9':
+ resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+
+ '@axe-core/playwright@4.13.0':
+ resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==}
+ peerDependencies:
+ playwright-core: '>= 1.0.0'
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.29.7':
+ resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.7':
+ resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.8':
+ resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.29.7':
+ resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.29.7':
+ resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.29.7':
+ resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.29.7':
+ resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@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/helper-validator-option@7.29.7':
+ resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.7':
+ resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.9':
+ resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.8':
+ resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.8':
+ resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
+
+ '@csstools/color-helpers@6.1.1':
+ resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/css-calc@3.4.0':
+ resolution: {integrity: sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-color-parser@4.2.3':
+ resolution: {integrity: sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0':
+ resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.14':
+ resolution: {integrity: sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==}
+ peerDependencies:
+ css-tree: ^3.2.1
+ peerDependenciesMeta:
+ css-tree:
+ optional: true
+
+ '@csstools/css-tokenizer@4.0.1':
+ resolution: {integrity: sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==}
+ engines: {node: '>=20.19.0'}
+
+ '@drizzle-team/brocli@0.10.2':
+ resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
+
+ '@envelop/core@5.6.1':
+ resolution: {integrity: sha512-KEbJ7yrAA+W7IY9MZzpZJ77w/72ndaV/16+hhPapd+0Ohjqffl0mmF3QWy62/m3nRP4qidD+1eiEjJhS/O38Tg==}
+ engines: {node: '>=18.0.0'}
+
+ '@envelop/instrumentation@1.0.1':
+ resolution: {integrity: sha512-FuexE2qd3Yc1Kh1DjTliwGxb9kyyv4ySBNnfJOxnQasVmGdcqX6AkkO5sIMxLWFRftp8PErOcIn2dQW/1RwytQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@envelop/types@5.2.2':
+ resolution: {integrity: sha512-nl9cmUh8F+2/NNzpcLjzNqtaVn0m8XSzzxrPU2ByHhlz4Hqlc5QBfRsemZkoJMKrMviJXyJYdKZiUP1o70F3Kw==}
+ engines: {node: '>=18.0.0'}
+
+ '@esbuild-kit/core-utils@3.3.2':
+ resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
+ deprecated: 'Merged into tsx: https://tsx.hirok.io'
+
+ '@esbuild-kit/esm-loader@2.6.5':
+ resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
+ deprecated: 'Merged into tsx: https://tsx.hirok.io'
+
+ '@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.2':
+ resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
+ 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.2':
+ resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
+ 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.2':
+ resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
+ 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.2':
+ resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
+ 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.2':
+ resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
+ 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.2':
+ resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
+ 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.2':
+ resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
+ 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.2':
+ resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
+ 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.2':
+ resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
+ 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.2':
+ resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
+ 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.2':
+ resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
+ 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.2':
+ resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
+ 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.2':
+ resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
+ 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.2':
+ resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
+ 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.2':
+ resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
+ 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.2':
+ resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
+ 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.2':
+ resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
+ 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.2':
+ resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
+ 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.2':
+ resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
+ 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.2':
+ resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
+ 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.2':
+ resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
+ 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.2':
+ resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
+ 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.2':
+ resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
+ 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.2':
+ resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
+ 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.2':
+ resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
+ 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.2':
+ resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@eslint-community/eslint-utils@4.10.1':
+ resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
+ 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.21.2':
+ resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/config-helpers@0.4.2':
+ resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/core@0.17.0':
+ resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/eslintrc@3.3.7':
+ resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/js@9.39.5':
+ resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/object-schema@2.1.7':
+ resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/plugin-kit@0.4.1':
+ resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@exodus/bytes@1.16.0':
+ resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
+ '@fastify/busboy@3.2.2':
+ resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==}
+
+ '@graphql-tools/executor@2.0.1':
+ resolution: {integrity: sha512-rvlOaoTUXUUHlu7090e94566SeBnU9PV6xMztIt2s8StXy5HbP0p+tYSfg3N/gCdEzJ78ZQLjXJz4UFgWGZ/7A==}
+ engines: {node: '>=16.0.0'}
+ peerDependencies:
+ graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
+
+ '@graphql-tools/merge@9.2.4':
+ resolution: {integrity: sha512-vV+8uWNWn0+OsqT0r22lZuoT0cTe6fBqBtpLHre2rriLjI/ZTrsOHmabLPTUOyzgFnRrS2PzFSYGL3zKL1Wj4Q==}
+ engines: {node: '>=16.0.0'}
+ peerDependencies:
+ graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
+
+ '@graphql-tools/schema@10.1.1':
+ resolution: {integrity: sha512-24jJghRxEW+SG1lbJ45Zg9HEZ8ZKS923ihbFjOicspFLpryxJkWp6gZJ7D8tCVaSAhli4x1+1wDPAfhT8mLqxg==}
+ engines: {node: '>=16.0.0'}
+ peerDependencies:
+ graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
+
+ '@graphql-tools/utils@11.2.2':
+ resolution: {integrity: sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw==}
+ engines: {node: '>=16.0.0'}
+ peerDependencies:
+ graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
+
+ '@graphql-tools/utils@12.0.1':
+ resolution: {integrity: sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg==}
+ engines: {node: '>=16.0.0'}
+ peerDependencies:
+ graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
+
+ '@graphql-typed-document-node/core@3.2.0':
+ resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==}
+ peerDependencies:
+ graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
+
+ '@graphql-yoga/logger@2.0.2':
+ resolution: {integrity: sha512-k2QGHCcH10O+GGe1sgXn0paaP9GCK91wxkuNQaA4IvXBF/RcdBLaX15UeZLW9kMG8kfz7bL0r4Yu+BXYe0JOaw==}
+ engines: {node: '>=18.0.0'}
+
+ '@graphql-yoga/subscription@5.1.1':
+ resolution: {integrity: sha512-I48DpyPTxYzmO5TwvKOXxiLI/tsbyeYNhb4/6/oJEBn6nlb8snHL0G0Fw1LC2ApNW1QKYuCPRDTp9yUwVBqhNQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@graphql-yoga/typed-event-target@3.0.3':
+ resolution: {integrity: sha512-j/EgVfZSzKA2lVeiZ0un8E2eVIqWa9Woyqta4A40ht1M93ui9CDVHU6pmphoQTpFa92BNbMVexXdOtMQk8LCjA==}
+ engines: {node: '>=18.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'}
+
+ '@inquirer/ansi@2.0.8':
+ resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
+
+ '@inquirer/confirm@6.3.2':
+ resolution: {integrity: sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/core@12.0.3':
+ resolution: {integrity: sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/figures@2.0.9':
+ resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
+
+ '@inquirer/type@4.1.1':
+ resolution: {integrity: sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.6.0':
+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@mswjs/interceptors@0.41.9':
+ resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==}
+ engines: {node: '>=18'}
+
+ '@open-draft/deferred-promise@2.2.0':
+ resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
+
+ '@open-draft/deferred-promise@3.0.0':
+ resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==}
+
+ '@open-draft/logger@0.3.0':
+ resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==}
+
+ '@open-draft/until@2.1.0':
+ resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
+
+ '@oxc-project/types@0.151.0':
+ resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==}
+
+ '@playwright/test@1.63.0':
+ resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ '@repeaterjs/repeater@3.1.0':
+ resolution: {integrity: sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==}
+
+ '@rolldown/binding-android-arm-eabi@1.2.10':
+ resolution: {integrity: sha512-bp9svZb+QurZeh+8H4BhrZkifEB0YBNvTVzNSJnJQkj4NrRwmQoDUCGP0vSN7PbvLeM7l1tK6GXL8mrTiH2myg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [android]
+
+ '@rolldown/binding-android-arm64@1.2.10':
+ resolution: {integrity: sha512-wm6Dld3RXUAZ/gRWKyUy+4W1B5CB5UeFaOzsSWJWEdxZXHH8rCYiZ5dGe6oJmhsunAPWzL7FZV+VtvmN5Ye2eA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@rolldown/binding-darwin-arm64@1.2.10':
+ resolution: {integrity: sha512-UbEfXq/AqGNgRTV3ik+X/iR6mUxu2QdYAadwRxJWquUGnW6gDqdP1FtLtFXRow7RJx0ssRwi80XAPr4r+4DtsA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rolldown/binding-darwin-x64@1.2.10':
+ resolution: {integrity: sha512-7f5h17q5KZVx/ji1vb8OTq31ch1O2I7K8NPIr44GkyWTApXMIsmhWqZfgpOH10xeauqghDAvGlZktasCkcF6Eg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rolldown/binding-freebsd-x64@1.2.10':
+ resolution: {integrity: sha512-ynOk/eEYhC6ZB2xCGvKrEOwE58oBy9LnrAqtkrDF9Fz1VTaNdGZTsV0VarJdhPwb+sOJTGjCLwcuyRJZ1dnMcQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.2.10':
+ resolution: {integrity: sha512-ERrAs185meZZhGan7a4l3RiiJK1ArSDlHdST++uvSxe+FDbR4TwUPahT/cbZJvaG6fIpDpF78surN+tX708Y4Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-gnu@1.2.10':
+ resolution: {integrity: sha512-KN7OHKD0J3jy1UzBwZWPxpwhODf9IARUIJcrH+yLYKOcmegZ8luEUM38lDP1bDVj40yP6PsSzCqOJF76vljFnQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-arm64-musl@1.2.10':
+ resolution: {integrity: sha512-8l9wP8O+wa8zD6iw6egSfzVtu7oZVfH3hlUsMM4MwbLMhxleqeoXbZzjddyK3YyNlwLhqznq3tF7PkNJ8T/V2w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-linux-ppc64-gnu@1.2.10':
+ resolution: {integrity: sha512-SeXNKeQzA5kLhz/J0CH6ZP0/HJ3v1xm/0YbiYpE0kK7emfRC2OIGGIaE14xzkISEGv2aYuUSpiLiU5Gbq+OI0A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-s390x-gnu@1.2.10':
+ resolution: {integrity: sha512-mtht0nR+y8/hart4175Ll15w7lY8dg7CtQ+j2FDNTsDRspOWTK/2V3l0aj9sIj7XmvqxT8Yli/wq22e7feTTWg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-gnu@1.2.10':
+ resolution: {integrity: sha512-FSM94nGd55NYo48usCyM/nHfUKRnqc9+b0vJNuKV0oCCpIp/OGims7rO1Nv/DkFkt0S/s2rxsJ2kkS8J3HcpeA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-musl@1.2.10':
+ resolution: {integrity: sha512-C3YxNB16myRLs7o+B+6PnQ6jBsdIS4+AE4Ah8glVGhDpEv9AOvxhZ/1duAb4B0UGczEK/lBbccksd8VI+p6zfw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-openharmony-arm64@1.2.10':
+ resolution: {integrity: sha512-571TlE/F1eeTjjdjYAMMMPs1Mfv3MtX6s3+ZKVU6HiUjZ5Njc6c/qzNy/8K3zALTZnaw3JQVYrHxvNfjm43KAg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rolldown/binding-win32-arm64-msvc@1.2.10':
+ resolution: {integrity: sha512-QXW+ZWaiqs2c7Fi++D/SsW07LTPcUrncxcskJGfGNBoaLik1IU6fJymz4HsqwEO0u5Iq11yTO0B/mc4cPk7jrQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rolldown/binding-win32-x64-msvc@1.2.10':
+ resolution: {integrity: sha512-5FQFGgah17YeMtG1Yd5a+rMxQpTksyNXxRtKz06FVTaQw3RKYUJQbUoKk0/5jrXBpDo+7makNP7UHA2LQyH64A==}
+ 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==}
+
+ '@testing-library/dom@10.4.2':
+ resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
+ engines: {node: '>=18'}
+
+ '@testing-library/jest-dom@7.0.1':
+ resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==}
+ engines: {node: '>=22', npm: '>=6', yarn: '>=1'}
+ peerDependencies:
+ '@testing-library/dom': '>=10 <11'
+ vitest: '>= 0.32'
+ peerDependenciesMeta:
+ vitest:
+ optional: true
+
+ '@testing-library/react@16.3.3':
+ resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@testing-library/user-event@14.6.7':
+ resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
+ '@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@24.13.6':
+ resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==}
+
+ '@types/pg@8.23.1':
+ resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
+
+ '@types/react-dom@19.3.0':
+ resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
+ peerDependencies:
+ '@types/react': ^19.3.0
+
+ '@types/react@19.3.0':
+ resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
+
+ '@types/set-cookie-parser@2.4.10':
+ resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==}
+
+ '@types/statuses@2.0.6':
+ resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
+
+ '@typescript-eslint/eslint-plugin@8.70.1':
+ resolution: {integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.70.1
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/parser@8.70.1':
+ resolution: {integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==}
+ 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.70.1':
+ resolution: {integrity: sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/scope-manager@8.70.1':
+ resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.70.1':
+ resolution: {integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/type-utils@8.70.1':
+ resolution: {integrity: sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==}
+ 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.70.1':
+ resolution: {integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.70.1':
+ resolution: {integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/utils@8.70.1':
+ resolution: {integrity: sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==}
+ 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.70.1':
+ resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@vitejs/plugin-react@6.1.1':
+ resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
+ babel-plugin-react-compiler: ^1.0.0
+ oxc-transform-react: ^0.145.0
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ '@rolldown/plugin-babel':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+ oxc-transform-react:
+ optional: true
+
+ '@vitest/expect@4.1.11':
+ resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
+
+ '@vitest/mocker@4.1.11':
+ resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==}
+ 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.11':
+ resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==}
+
+ '@vitest/runner@4.1.11':
+ resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==}
+
+ '@vitest/snapshot@4.1.11':
+ resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==}
+
+ '@vitest/spy@4.1.11':
+ resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==}
+
+ '@vitest/utils@4.1.11':
+ resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
+
+ '@whatwg-node/disposablestack@0.0.6':
+ resolution: {integrity: sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==}
+ engines: {node: '>=18.0.0'}
+
+ '@whatwg-node/events@0.1.2':
+ resolution: {integrity: sha512-ApcWxkrs1WmEMS2CaLLFUEem/49erT3sxIVjpzU5f6zmVcnijtDSrhoK2zVobOIikZJdH63jdAXOrvjf6eOUNQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@whatwg-node/fetch@0.10.13':
+ resolution: {integrity: sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==}
+ engines: {node: '>=18.0.0'}
+
+ '@whatwg-node/node-fetch@0.8.6':
+ resolution: {integrity: sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==}
+ engines: {node: '>=18.0.0'}
+
+ '@whatwg-node/promise-helpers@1.3.2':
+ resolution: {integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==}
+ engines: {node: '>=16.0.0'}
+
+ '@whatwg-node/server@0.11.0':
+ resolution: {integrity: sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@wry/caches@1.0.1':
+ resolution: {integrity: sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==}
+ engines: {node: '>=8'}
+
+ '@wry/context@0.7.4':
+ resolution: {integrity: sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==}
+ engines: {node: '>=8'}
+
+ '@wry/equality@0.5.7':
+ resolution: {integrity: sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==}
+ engines: {node: '>=8'}
+
+ '@wry/trie@0.5.0':
+ resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==}
+ engines: {node: '>=8'}
+
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.18.0:
+ resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ ajv@6.15.0:
+ resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
+
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
+ aria-query@5.3.2:
+ resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
+ engines: {node: '>= 0.4'}
+
+ array-buffer-byte-length@1.0.2:
+ resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
+ engines: {node: '>= 0.4'}
+
+ array-includes@3.2.0:
+ resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flat@1.3.3:
+ resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flatmap@1.3.3:
+ resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
+ engines: {node: '>= 0.4'}
+
+ arraybuffer.prototype.slice@1.0.4:
+ resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
+ engines: {node: '>= 0.4'}
+
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
+ ast-types-flow@0.0.8:
+ resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
+
+ async-function@1.0.0:
+ resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
+ engines: {node: '>= 0.4'}
+
+ available-typed-arrays@1.0.7:
+ resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
+ engines: {node: '>= 0.4'}
+
+ axe-core@4.13.0:
+ resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
+ engines: {node: '>=4'}
+
+ axobject-query@4.1.0:
+ resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
+ engines: {node: '>= 0.4'}
+
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+ balanced-match@4.0.4:
+ resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+ engines: {node: 18 || 20 || >=22}
+
+ baseline-browser-mapping@2.11.25:
+ resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ bidi-js@1.1.0:
+ resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==}
+
+ brace-expansion@1.1.21:
+ resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
+
+ brace-expansion@5.0.12:
+ resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
+ engines: {node: 20 || >=22}
+
+ browserslist@4.29.0:
+ resolution: {integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ buffer-from@1.1.2:
+ resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bind@1.0.9:
+ resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
+
+ callsites@3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+
+ caniuse-lite@1.0.30001810:
+ resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
+
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
+
+ chalk@4.1.2:
+ resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+ engines: {node: '>=10'}
+
+ cli-width@4.1.0:
+ resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
+ engines: {node: '>= 12'}
+
+ cliui@8.0.1:
+ resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
+ engines: {node: '>=12'}
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
+ cross-inspect@1.0.1:
+ resolution: {integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==}
+ engines: {node: '>=16.0.0'}
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ css-tree@3.2.1:
+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
+ css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ damerau-levenshtein@1.0.8:
+ resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
+
+ data-urls@7.0.0:
+ resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ data-view-buffer@1.0.2:
+ resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
+ engines: {node: '>= 0.4'}
+
+ data-view-byte-length@1.0.2:
+ resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
+ engines: {node: '>= 0.4'}
+
+ data-view-byte-offset@1.0.1:
+ resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
+ engines: {node: '>= 0.4'}
+
+ dataloader@2.2.3:
+ resolution: {integrity: sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
+ deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+ define-data-property@1.1.4:
+ resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
+ engines: {node: '>= 0.4'}
+
+ define-properties@1.2.1:
+ resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
+ engines: {node: '>= 0.4'}
+
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
+ dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
+ dotenv@17.4.2:
+ resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
+ engines: {node: '>=12'}
+
+ drizzle-kit@0.31.11:
+ resolution: {integrity: sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==}
+ hasBin: true
+
+ drizzle-orm@0.45.3:
+ resolution: {integrity: sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA==}
+ 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'
+ '@netlify/db': '>=0.4.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
+ '@netlify/db':
+ 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
+
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ electron-to-chromium@1.5.438:
+ resolution: {integrity: sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA==}
+
+ emoji-regex@8.0.0:
+ resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
+ emoji-regex@9.2.2:
+ resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+
+ entities@8.1.0:
+ resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
+ engines: {node: '>=20.19.0'}
+
+ es-abstract-get@1.0.0:
+ resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
+ engines: {node: '>= 0.4'}
+
+ es-abstract@1.24.2:
+ resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
+ engines: {node: '>= 0.4'}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-module-lexer@2.3.2:
+ resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
+
+ es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+ engines: {node: '>= 0.4'}
+
+ es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+
+ es-shim-unscopables@1.1.0:
+ resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
+ engines: {node: '>= 0.4'}
+
+ es-to-primitive@1.3.4:
+ resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
+ engines: {node: '>= 0.4'}
+
+ 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.2:
+ resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ eslint-config-prettier@10.1.8:
+ resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
+ hasBin: true
+ peerDependencies:
+ eslint: '>=7.0.0'
+
+ eslint-plugin-jsx-a11y@6.10.2:
+ resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
+
+ eslint-plugin-react-hooks@7.1.1:
+ resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
+
+ eslint-plugin-react-refresh@0.5.7:
+ resolution: {integrity: sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==}
+ peerDependencies:
+ eslint: ^9 || ^10
+
+ eslint-scope@8.4.0:
+ resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ 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@4.2.1:
+ resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@5.0.1:
+ resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ eslint@9.39.5:
+ resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ espree@10.4.0:
+ resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ 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.4.0:
+ resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
+ 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==}
+
+ fast-string-truncated-width@3.0.3:
+ resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
+
+ fast-string-width@3.0.2:
+ resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
+
+ fast-wrap-ansi@0.2.2:
+ resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
+
+ 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.4:
+ resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
+
+ for-each@0.3.5:
+ resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
+ engines: {node: '>= 0.4'}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ function.prototype.name@1.2.0:
+ resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==}
+ engines: {node: '>= 0.4'}
+
+ functions-have-names@1.2.3:
+ resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
+
+ generator-function@2.0.1:
+ resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
+ engines: {node: '>= 0.4'}
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ get-caller-file@2.0.5:
+ resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+ engines: {node: 6.* || 8.* || >= 10.*}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ get-symbol-description@1.1.0:
+ resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
+ engines: {node: '>= 0.4'}
+
+ get-tsconfig@4.14.3:
+ resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ globals@14.0.0:
+ resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+ engines: {node: '>=18'}
+
+ globals@17.12.0:
+ resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==}
+ engines: {node: '>=18'}
+
+ globalthis@1.0.4:
+ resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
+ engines: {node: '>= 0.4'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
+ graphql-tag@2.12.7:
+ resolution: {integrity: sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
+
+ graphql-yoga@5.24.1:
+ resolution: {integrity: sha512-NA2aQ4sZvR/bniCCLOlMG6OSZf5hPg3mUf3Phm33ObqRMZEq6WtZrj2hTcHfT2ZEFVV5FRCbcMb4NB1w7BIKaA==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ graphql: ^15.2.0 || ^16.0.0 || ^17.0.0
+
+ graphql@16.14.2:
+ resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==}
+ engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
+
+ has-bigints@1.1.0:
+ resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
+ engines: {node: '>= 0.4'}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ has-property-descriptors@1.0.2:
+ resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
+
+ has-proto@1.2.0:
+ resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
+ engines: {node: '>= 0.4'}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
+ headers-polyfill@5.0.1:
+ resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
+
+ hermes-estree@0.25.1:
+ resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
+
+ hermes-parser@0.25.1:
+ resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ ignore@5.3.2:
+ resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+ engines: {node: '>= 4'}
+
+ ignore@7.0.10:
+ resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==}
+ engines: {node: '>= 4'}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
+ imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+ engines: {node: '>=0.8.19'}
+
+ indent-string@4.0.0:
+ resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+ engines: {node: '>=8'}
+
+ internal-slot@1.1.0:
+ resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
+ engines: {node: '>= 0.4'}
+
+ is-array-buffer@3.0.5:
+ resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
+ engines: {node: '>= 0.4'}
+
+ is-async-function@2.1.1:
+ resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
+ engines: {node: '>= 0.4'}
+
+ is-bigint@1.1.0:
+ resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
+ engines: {node: '>= 0.4'}
+
+ is-boolean-object@1.2.2:
+ resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
+ engines: {node: '>= 0.4'}
+
+ is-callable@1.2.7:
+ resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
+ engines: {node: '>= 0.4'}
+
+ is-data-view@1.0.2:
+ resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
+ engines: {node: '>= 0.4'}
+
+ is-date-object@1.1.0:
+ resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
+ engines: {node: '>= 0.4'}
+
+ is-document.all@1.0.0:
+ resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
+ engines: {node: '>= 0.4'}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-finalizationregistry@1.1.1:
+ resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
+ engines: {node: '>= 0.4'}
+
+ is-fullwidth-code-point@3.0.0:
+ resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+ engines: {node: '>=8'}
+
+ is-generator-function@1.1.2:
+ resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
+ engines: {node: '>= 0.4'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ is-map@2.0.3:
+ resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
+ engines: {node: '>= 0.4'}
+
+ is-negative-zero@2.0.3:
+ resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
+ engines: {node: '>= 0.4'}
+
+ is-node-process@1.2.0:
+ resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==}
+
+ is-number-object@1.1.1:
+ resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
+ engines: {node: '>= 0.4'}
+
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
+ is-regex@1.2.1:
+ resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
+ engines: {node: '>= 0.4'}
+
+ is-set@2.0.3:
+ resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
+ engines: {node: '>= 0.4'}
+
+ is-shared-array-buffer@1.0.4:
+ resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
+ engines: {node: '>= 0.4'}
+
+ is-string@1.1.1:
+ resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
+ engines: {node: '>= 0.4'}
+
+ is-symbol@1.1.1:
+ resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
+ engines: {node: '>= 0.4'}
+
+ is-typed-array@1.1.15:
+ resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
+ engines: {node: '>= 0.4'}
+
+ is-weakmap@2.0.2:
+ resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
+ engines: {node: '>= 0.4'}
+
+ is-weakref@1.1.1:
+ resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
+ engines: {node: '>= 0.4'}
+
+ is-weakset@2.0.4:
+ resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
+ engines: {node: '>= 0.4'}
+
+ isarray@2.0.5:
+ resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ js-yaml@4.3.2:
+ resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
+ hasBin: true
+
+ jsdom@29.1.1:
+ resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ 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==}
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ jsx-ast-utils@3.3.5:
+ resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
+ engines: {node: '>=4.0'}
+
+ keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+ language-subtag-registry@0.3.23:
+ resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
+
+ language-tags@1.0.9:
+ resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
+ engines: {node: '>=0.10'}
+
+ levn@0.4.1:
+ resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+ engines: {node: '>= 0.8.0'}
+
+ lightningcss-android-arm64@1.33.0:
+ resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.33.0:
+ resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.33.0:
+ resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.33.0:
+ resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.33.0:
+ resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.33.0:
+ resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.33.0:
+ resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.33.0:
+ resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.33.0:
+ resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-win32-arm64-msvc@1.33.0:
+ resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.33.0:
+ resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.33.0:
+ resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
+ engines: {node: '>= 12.0.0'}
+
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
+ lodash.merge@4.6.2:
+ resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
+ lru-cache@10.4.3:
+ resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
+
+ lru-cache@11.5.3:
+ resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
+ engines: {node: 20 || >=22}
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
+ mdn-data@2.27.1:
+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
+ min-indent@1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+
+ minimatch@10.2.6:
+ resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
+ engines: {node: 18 || 20 || >=22}
+
+ minimatch@3.1.5:
+ resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ msw@2.15.0:
+ resolution: {integrity: sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==}
+ engines: {node: '>=18'}
+ hasBin: true
+ peerDependencies:
+ typescript: '>= 4.8.x'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ mute-stream@3.0.0:
+ resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+
+ nanoid@3.3.19:
+ resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+ node-releases@2.0.57:
+ resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
+ engines: {node: '>=18'}
+
+ object-inspect@1.13.4:
+ resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+ engines: {node: '>= 0.4'}
+
+ object-keys@1.1.1:
+ resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
+ engines: {node: '>= 0.4'}
+
+ object.assign@4.1.7:
+ resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
+ engines: {node: '>= 0.4'}
+
+ object.fromentries@2.0.8:
+ resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
+ engines: {node: '>= 0.4'}
+
+ object.values@1.2.1:
+ resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
+ engines: {node: '>= 0.4'}
+
+ obug@2.2.1:
+ resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
+ engines: {node: '>=12.20.0'}
+
+ optimism@0.18.1:
+ resolution: {integrity: sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==}
+
+ optionator@0.9.4:
+ resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+ engines: {node: '>= 0.8.0'}
+
+ outvariant@1.4.3:
+ resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
+
+ own-keys@1.0.2:
+ resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
+ engines: {node: '>= 0.4'}
+
+ 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'}
+
+ parent-module@1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+
+ parse5@8.0.1:
+ resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+
+ 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'}
+
+ path-to-regexp@6.3.0:
+ resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
+ pg-cloudflare@1.4.0:
+ resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
+
+ pg-connection-string@2.14.0:
+ resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==}
+
+ pg-int8@1.0.1:
+ resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
+ engines: {node: '>=4.0.0'}
+
+ pg-pool@3.14.0:
+ resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
+ peerDependencies:
+ pg: '>=8.0'
+
+ pg-protocol@1.16.0:
+ resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
+
+ pg-types@2.2.0:
+ resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
+ engines: {node: '>=4'}
+
+ pg@8.23.0:
+ resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
+ engines: {node: '>= 16.0.0'}
+ peerDependencies:
+ pg-native: '>=3.0.1'
+ peerDependenciesMeta:
+ pg-native:
+ optional: true
+
+ pgpass@1.0.5:
+ resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.7:
+ resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
+ engines: {node: '>=12'}
+
+ playwright-core@1.63.0:
+ resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ playwright@1.63.0:
+ resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ possible-typed-array-names@1.1.0:
+ resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
+ engines: {node: '>= 0.4'}
+
+ postcss@8.5.28:
+ resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ postgres-array@2.0.0:
+ resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
+ engines: {node: '>=4'}
+
+ postgres-bytea@1.0.1:
+ resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
+ engines: {node: '>=0.10.0'}
+
+ postgres-date@1.0.7:
+ resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
+ engines: {node: '>=0.10.0'}
+
+ postgres-interval@1.2.0:
+ resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
+ engines: {node: '>=0.10.0'}
+
+ prelude-ls@1.2.1:
+ resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+ engines: {node: '>= 0.8.0'}
+
+ prettier@3.9.9:
+ resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==}
+ engines: {node: '>=14'}
+ hasBin: true
+
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
+ react-dom@19.3.0:
+ resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==}
+ peerDependencies:
+ react: ^19.3.0
+
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
+ react-router-dom@7.18.4:
+ resolution: {integrity: sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+
+ react-router@7.18.4:
+ resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
+ react@19.3.0:
+ resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
+ engines: {node: '>=0.10.0'}
+
+ redent@3.0.0:
+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+ engines: {node: '>=8'}
+
+ reflect.getprototypeof@1.0.10:
+ resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
+ engines: {node: '>= 0.4'}
+
+ regexp.prototype.flags@1.5.4:
+ resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
+ engines: {node: '>= 0.4'}
+
+ require-directory@2.1.1:
+ resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+ engines: {node: '>=0.10.0'}
+
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
+ resolve-pkg-maps@1.0.0:
+ resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+
+ rettime@0.11.11:
+ resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==}
+
+ rolldown@1.2.10:
+ resolution: {integrity: sha512-OxkA08pSryMK7B3XiFA09B4OJ1xJMPgIYCBMY2xchzpqgBGsV1o0DetPAE+Sl3N3L4oCPiEzmHVSOj7iR04Zog==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
+ rxjs@7.8.2:
+ resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
+
+ safe-array-concat@1.1.4:
+ resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
+ engines: {node: '>=0.4'}
+
+ safe-push-apply@1.0.0:
+ resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
+ engines: {node: '>= 0.4'}
+
+ safe-regex-test@1.1.0:
+ resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
+ engines: {node: '>= 0.4'}
+
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
+ scheduler@0.28.0:
+ resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
+ set-cookie-parser@3.1.2:
+ resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
+
+ set-function-length@1.2.2:
+ resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
+ engines: {node: '>= 0.4'}
+
+ set-function-name@2.0.2:
+ resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
+ engines: {node: '>= 0.4'}
+
+ set-proto@1.0.0:
+ resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
+ engines: {node: '>= 0.4'}
+
+ 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'}
+
+ side-channel-list@1.0.1:
+ resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-map@1.0.1:
+ resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-weakmap@1.0.2:
+ resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+ engines: {node: '>= 0.4'}
+
+ side-channel@1.1.1:
+ resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
+ engines: {node: '>= 0.4'}
+
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
+ signal-exit@4.1.0:
+ resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
+ engines: {node: '>=14'}
+
+ 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'}
+
+ split2@4.2.0:
+ resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
+ engines: {node: '>= 10.x'}
+
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+ statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+
+ std-env@4.2.0:
+ resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
+
+ stop-iteration-iterator@1.1.0:
+ resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
+ engines: {node: '>= 0.4'}
+
+ strict-event-emitter@0.5.1:
+ resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
+
+ string-width@4.2.3:
+ resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+ engines: {node: '>=8'}
+
+ string.prototype.includes@2.0.1:
+ resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trim@1.2.11:
+ resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trimend@1.0.10:
+ resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trimstart@1.0.8:
+ resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
+ engines: {node: '>= 0.4'}
+
+ strip-ansi@6.0.1:
+ resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+ engines: {node: '>=8'}
+
+ strip-indent@3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+
+ strip-json-comments@3.1.1:
+ resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+ engines: {node: '>=8'}
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
+ tagged-tag@1.0.0:
+ resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
+ engines: {node: '>=20'}
+
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@1.3.1:
+ resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==}
+ engines: {node: '>=18'}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ tinyrainbow@3.1.1:
+ resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
+ engines: {node: '>=14.0.0'}
+
+ tldts-core@7.4.14:
+ resolution: {integrity: sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==}
+
+ tldts@7.4.14:
+ resolution: {integrity: sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==}
+ hasBin: true
+
+ tough-cookie@6.0.2:
+ resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
+ engines: {node: '>=16'}
+
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
+ 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.23.15:
+ resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
+ type-check@0.4.0:
+ resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+ engines: {node: '>= 0.8.0'}
+
+ type-fest@5.10.0:
+ resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==}
+ engines: {node: '>=20'}
+
+ typed-array-buffer@1.0.3:
+ resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-byte-length@1.0.3:
+ resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-byte-offset@1.0.5:
+ resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-length@1.0.8:
+ resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
+ engines: {node: '>= 0.4'}
+
+ typescript-eslint@8.70.1:
+ resolution: {integrity: sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==}
+ 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@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ unbox-primitive@1.1.0:
+ resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
+ engines: {node: '>= 0.4'}
+
+ undici-types@7.18.2:
+ resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
+
+ undici@7.29.1:
+ resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==}
+ engines: {node: '>=20.18.1'}
+
+ until-async@3.0.2:
+ resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==}
+
+ update-browserslist-db@1.3.3:
+ resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ uri-js@4.4.1:
+ resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+ urlpattern-polyfill@10.1.0:
+ resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==}
+
+ vite@8.3.0:
+ resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.7.1
+ 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.11:
+ resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==}
+ 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.11
+ '@vitest/browser-preview': 4.1.11
+ '@vitest/browser-webdriverio': 4.1.11
+ '@vitest/coverage-istanbul': 4.1.11
+ '@vitest/coverage-v8': 4.1.11
+ '@vitest/ui': 4.1.11
+ 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
+
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
+ whatwg-url@16.0.1:
+ resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ which-boxed-primitive@1.1.1:
+ resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
+ engines: {node: '>= 0.4'}
+
+ which-builtin-type@1.2.1:
+ resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
+ engines: {node: '>= 0.4'}
+
+ which-collection@1.0.2:
+ resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
+ engines: {node: '>= 0.4'}
+
+ which-typed-array@1.1.24:
+ resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==}
+ engines: {node: '>= 0.4'}
+
+ 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'}
+
+ wrap-ansi@7.0.0:
+ resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+ engines: {node: '>=10'}
+
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
+ xtend@4.0.2:
+ resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
+ engines: {node: '>=0.4'}
+
+ y18n@5.0.8:
+ resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
+ engines: {node: '>=10'}
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+ yargs-parser@21.1.1:
+ resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
+ engines: {node: '>=12'}
+
+ yargs@17.7.3:
+ resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
+ engines: {node: '>=12'}
+
+ yocto-queue@0.1.0:
+ resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+ engines: {node: '>=10'}
+
+ zod-validation-error@4.0.2:
+ resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+
+ zod@4.6.5:
+ resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
+
+snapshots:
+
+ '@adobe/css-tools@4.5.0': {}
+
+ '@apollo/client@4.3.1(graphql@16.14.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rxjs@7.8.2)':
+ dependencies:
+ '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2)
+ '@wry/caches': 1.0.1
+ '@wry/equality': 0.5.7
+ '@wry/trie': 0.5.0
+ graphql: 16.14.2
+ graphql-tag: 2.12.7(graphql@16.14.2)
+ optimism: 0.18.1
+ rxjs: 7.8.2
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.3.0
+ react-dom: 19.3.0(react@19.3.0)
+
+ '@asamuzakjp/css-color@5.1.11':
+ dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@csstools/css-calc': 3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1)
+ '@csstools/css-color-parser': 4.2.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.1)
+ '@csstools/css-tokenizer': 4.0.1
+
+ '@asamuzakjp/dom-selector@7.1.1':
+ dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@asamuzakjp/nwsapi': 2.3.9
+ bidi-js: 1.1.0
+ css-tree: 3.2.1
+ is-potential-custom-element-name: 1.0.1
+
+ '@asamuzakjp/generational-cache@1.0.1': {}
+
+ '@asamuzakjp/nwsapi@2.3.9': {}
+
+ '@axe-core/playwright@4.13.0(playwright-core@1.63.0)':
+ dependencies:
+ axe-core: 4.13.0
+ playwright-core: 1.63.0
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.29.7': {}
+
+ '@babel/core@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.8
+ '@babel/helper-compilation-targets': 7.29.7
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
+ '@babel/helpers': 7.29.7
+ '@babel/parser': 7.29.9
+ '@babel/template': 7.29.7
+ '@babel/traverse': 7.29.8
+ '@babel/types': 7.29.8
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.8':
+ dependencies:
+ '@babel/parser': 7.29.9
+ '@babel/types': 7.29.8
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.29.7':
+ dependencies:
+ '@babel/compat-data': 7.29.7
+ '@babel/helper-validator-option': 7.29.7
+ browserslist: 4.29.0
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.29.7': {}
+
+ '@babel/helper-module-imports@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.8
+ '@babel/types': 7.29.8
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+ '@babel/traverse': 7.29.8
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/helper-validator-option@7.29.7': {}
+
+ '@babel/helpers@7.29.7':
+ dependencies:
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.8
+
+ '@babel/parser@7.29.9':
+ dependencies:
+ '@babel/types': 7.29.8
+
+ '@babel/runtime@7.29.7': {}
+
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.9
+ '@babel/types': 7.29.8
+
+ '@babel/traverse@7.29.8':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.8
+ '@babel/helper-globals': 7.29.7
+ '@babel/parser': 7.29.9
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.8
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.8':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
+
+ '@csstools/color-helpers@6.1.1': {}
+
+ '@csstools/css-calc@3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.1)
+ '@csstools/css-tokenizer': 4.0.1
+
+ '@csstools/css-color-parser@4.2.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1)':
+ dependencies:
+ '@csstools/color-helpers': 6.1.1
+ '@csstools/css-calc': 3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.1)
+ '@csstools/css-tokenizer': 4.0.1
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.1
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.14(css-tree@3.2.1)':
+ optionalDependencies:
+ css-tree: 3.2.1
+
+ '@csstools/css-tokenizer@4.0.1': {}
+
+ '@drizzle-team/brocli@0.10.2': {}
+
+ '@envelop/core@5.6.1':
+ dependencies:
+ '@envelop/instrumentation': 1.0.1
+ '@envelop/types': 5.2.2
+ '@whatwg-node/promise-helpers': 1.3.2
+ tslib: 2.8.1
+
+ '@envelop/instrumentation@1.0.1':
+ dependencies:
+ '@whatwg-node/promise-helpers': 1.3.2
+ tslib: 2.8.1
+
+ '@envelop/types@5.2.2':
+ dependencies:
+ '@whatwg-node/promise-helpers': 1.3.2
+ tslib: 2.8.1
+
+ '@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.3
+
+ '@esbuild/aix-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/aix-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm@0.18.20':
+ optional: true
+
+ '@esbuild/android-arm@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm@0.28.2':
+ optional: true
+
+ '@esbuild/android-x64@0.18.20':
+ optional: true
+
+ '@esbuild/android-x64@0.25.12':
+ optional: true
+
+ '@esbuild/android-x64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.18.20':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-x64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.18.20':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.18.20':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-loong64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.18.20':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.12':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.18.20':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.12':
+ optional: true
+
+ '@esbuild/linux-s390x@0.28.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-x64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.18.20':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.12':
+ optional: true
+
+ '@esbuild/sunos-x64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.18.20':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/win32-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.18.20':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-x64@0.28.2':
+ optional: true
+
+ '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5)':
+ dependencies:
+ eslint: 9.39.5
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.2': {}
+
+ '@eslint/config-array@0.21.2':
+ dependencies:
+ '@eslint/object-schema': 2.1.7
+ debug: 4.4.3
+ minimatch: 3.1.5
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/config-helpers@0.4.2':
+ dependencies:
+ '@eslint/core': 0.17.0
+
+ '@eslint/core@0.17.0':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/eslintrc@3.3.7':
+ dependencies:
+ ajv: 6.15.0
+ debug: 4.4.3
+ espree: 10.4.0
+ globals: 14.0.0
+ ignore: 5.3.2
+ import-fresh: 3.3.1
+ js-yaml: 4.3.2
+ minimatch: 3.1.5
+ strip-json-comments: 3.1.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/js@9.39.5': {}
+
+ '@eslint/object-schema@2.1.7': {}
+
+ '@eslint/plugin-kit@0.4.1':
+ dependencies:
+ '@eslint/core': 0.17.0
+ levn: 0.4.1
+
+ '@exodus/bytes@1.16.0': {}
+
+ '@fastify/busboy@3.2.2': {}
+
+ '@graphql-tools/executor@2.0.1(graphql@16.14.2)':
+ dependencies:
+ '@graphql-tools/utils': 12.0.1(graphql@16.14.2)
+ '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2)
+ '@repeaterjs/repeater': 3.1.0
+ '@whatwg-node/disposablestack': 0.0.6
+ '@whatwg-node/promise-helpers': 1.3.2
+ graphql: 16.14.2
+ tslib: 2.8.1
+
+ '@graphql-tools/merge@9.2.4(graphql@16.14.2)':
+ dependencies:
+ '@graphql-tools/utils': 12.0.1(graphql@16.14.2)
+ graphql: 16.14.2
+ tslib: 2.8.1
+
+ '@graphql-tools/schema@10.1.1(graphql@16.14.2)':
+ dependencies:
+ '@graphql-tools/merge': 9.2.4(graphql@16.14.2)
+ '@graphql-tools/utils': 12.0.1(graphql@16.14.2)
+ graphql: 16.14.2
+ tslib: 2.8.1
+
+ '@graphql-tools/utils@11.2.2(graphql@16.14.2)':
+ dependencies:
+ '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2)
+ '@whatwg-node/promise-helpers': 1.3.2
+ cross-inspect: 1.0.1
+ graphql: 16.14.2
+ tslib: 2.8.1
+
+ '@graphql-tools/utils@12.0.1(graphql@16.14.2)':
+ dependencies:
+ '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2)
+ '@whatwg-node/promise-helpers': 1.3.2
+ cross-inspect: 1.0.1
+ graphql: 16.14.2
+ tslib: 2.8.1
+
+ '@graphql-typed-document-node/core@3.2.0(graphql@16.14.2)':
+ dependencies:
+ graphql: 16.14.2
+
+ '@graphql-yoga/logger@2.0.2':
+ dependencies:
+ tslib: 2.8.1
+
+ '@graphql-yoga/subscription@5.1.1':
+ dependencies:
+ '@graphql-yoga/typed-event-target': 3.0.3
+ '@repeaterjs/repeater': 3.1.0
+ '@whatwg-node/events': 0.1.2
+ tslib: 2.8.1
+
+ '@graphql-yoga/typed-event-target@3.0.3':
+ dependencies:
+ '@repeaterjs/repeater': 3.1.0
+ tslib: 2.8.1
+
+ '@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': {}
+
+ '@inquirer/ansi@2.0.8': {}
+
+ '@inquirer/confirm@6.3.2(@types/node@24.13.6)':
+ dependencies:
+ '@inquirer/core': 12.0.3(@types/node@24.13.6)
+ '@inquirer/type': 4.1.1(@types/node@24.13.6)
+ optionalDependencies:
+ '@types/node': 24.13.6
+
+ '@inquirer/core@12.0.3(@types/node@24.13.6)':
+ dependencies:
+ '@inquirer/ansi': 2.0.8
+ '@inquirer/figures': 2.0.9
+ '@inquirer/type': 4.1.1(@types/node@24.13.6)
+ cli-width: 4.1.0
+ fast-wrap-ansi: 0.2.2
+ mute-stream: 3.0.0
+ signal-exit: 4.1.0
+ optionalDependencies:
+ '@types/node': 24.13.6
+
+ '@inquirer/figures@2.0.9': {}
+
+ '@inquirer/type@4.1.1(@types/node@24.13.6)':
+ optionalDependencies:
+ '@types/node': 24.13.6
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.6.0': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ '@mswjs/interceptors@0.41.9':
+ dependencies:
+ '@open-draft/deferred-promise': 2.2.0
+ '@open-draft/logger': 0.3.0
+ '@open-draft/until': 2.1.0
+ is-node-process: 1.2.0
+ outvariant: 1.4.3
+ strict-event-emitter: 0.5.1
+
+ '@open-draft/deferred-promise@2.2.0': {}
+
+ '@open-draft/deferred-promise@3.0.0': {}
+
+ '@open-draft/logger@0.3.0':
+ dependencies:
+ is-node-process: 1.2.0
+ outvariant: 1.4.3
+
+ '@open-draft/until@2.1.0': {}
+
+ '@oxc-project/types@0.151.0': {}
+
+ '@playwright/test@1.63.0':
+ dependencies:
+ playwright: 1.63.0
+
+ '@repeaterjs/repeater@3.1.0': {}
+
+ '@rolldown/binding-android-arm-eabi@1.2.10':
+ optional: true
+
+ '@rolldown/binding-android-arm64@1.2.10':
+ optional: true
+
+ '@rolldown/binding-darwin-arm64@1.2.10':
+ optional: true
+
+ '@rolldown/binding-darwin-x64@1.2.10':
+ optional: true
+
+ '@rolldown/binding-freebsd-x64@1.2.10':
+ optional: true
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.2.10':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-gnu@1.2.10':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-musl@1.2.10':
+ optional: true
+
+ '@rolldown/binding-linux-ppc64-gnu@1.2.10':
+ optional: true
+
+ '@rolldown/binding-linux-s390x-gnu@1.2.10':
+ optional: true
+
+ '@rolldown/binding-linux-x64-gnu@1.2.10':
+ optional: true
+
+ '@rolldown/binding-linux-x64-musl@1.2.10':
+ optional: true
+
+ '@rolldown/binding-openharmony-arm64@1.2.10':
+ optional: true
+
+ '@rolldown/binding-win32-arm64-msvc@1.2.10':
+ optional: true
+
+ '@rolldown/binding-win32-x64-msvc@1.2.10':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.1': {}
+
+ '@standard-schema/spec@1.1.0': {}
+
+ '@testing-library/dom@10.4.2':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/runtime': 7.29.7
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@4.1.11(@types/node@24.13.6)(jsdom@29.1.1)(msw@2.15.0(@types/node@24.13.6)(typescript@5.9.3))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15)))':
+ dependencies:
+ '@adobe/css-tools': 4.5.0
+ '@testing-library/dom': 10.4.2
+ aria-query: 5.3.2
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ picocolors: 1.1.1
+ redent: 3.0.0
+ optionalDependencies:
+ vitest: 4.1.11(@types/node@24.13.6)(jsdom@29.1.1)(msw@2.15.0(@types/node@24.13.6)(typescript@5.9.3))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15))
+
+ '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@testing-library/dom': 10.4.2
+ react: 19.3.0
+ react-dom: 19.3.0(react@19.3.0)
+ optionalDependencies:
+ '@types/react': 19.3.0
+ '@types/react-dom': 19.3.0(@types/react@19.3.0)
+
+ '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.2)':
+ dependencies:
+ '@testing-library/dom': 10.4.2
+
+ '@types/aria-query@5.0.4': {}
+
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
+ '@types/deep-eql@4.0.2': {}
+
+ '@types/estree@1.0.9': {}
+
+ '@types/json-schema@7.0.15': {}
+
+ '@types/node@24.13.6':
+ dependencies:
+ undici-types: 7.18.2
+
+ '@types/pg@8.23.1':
+ dependencies:
+ '@types/node': 24.13.6
+ pg-protocol: 1.16.0
+ pg-types: 2.2.0
+
+ '@types/react-dom@19.3.0(@types/react@19.3.0)':
+ dependencies:
+ '@types/react': 19.3.0
+
+ '@types/react@19.3.0':
+ dependencies:
+ csstype: 3.2.3
+
+ '@types/set-cookie-parser@2.4.10':
+ dependencies:
+ '@types/node': 24.13.6
+
+ '@types/statuses@2.0.6': {}
+
+ '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.70.1(eslint@9.39.5)(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.70.1
+ '@typescript-eslint/type-utils': 8.70.1(eslint@9.39.5)(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.70.1(eslint@9.39.5)(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.70.1
+ eslint: 9.39.5
+ ignore: 7.0.10
+ natural-compare: 1.4.0
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.70.1(eslint@9.39.5)(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.70.1
+ '@typescript-eslint/types': 8.70.1
+ '@typescript-eslint/typescript-estree': 8.70.1(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.70.1
+ debug: 4.4.3
+ eslint: 9.39.5
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.70.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.70.1
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.70.1':
+ dependencies:
+ '@typescript-eslint/types': 8.70.1
+ '@typescript-eslint/visitor-keys': 8.70.1
+
+ '@typescript-eslint/tsconfig-utils@8.70.1(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/type-utils@8.70.1(eslint@9.39.5)(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.70.1
+ '@typescript-eslint/typescript-estree': 8.70.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.70.1(eslint@9.39.5)(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 9.39.5
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.70.1': {}
+
+ '@typescript-eslint/typescript-estree@8.70.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.70.1(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.70.1
+ '@typescript-eslint/visitor-keys': 8.70.1
+ debug: 4.4.3
+ minimatch: 10.2.6
+ semver: 7.8.5
+ tinyglobby: 0.2.17
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.70.1(eslint@9.39.5)(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5)
+ '@typescript-eslint/scope-manager': 8.70.1
+ '@typescript-eslint/types': 8.70.1
+ '@typescript-eslint/typescript-estree': 8.70.1(typescript@5.9.3)
+ eslint: 9.39.5
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.70.1':
+ dependencies:
+ '@typescript-eslint/types': 8.70.1
+ eslint-visitor-keys: 5.0.1
+
+ '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.1
+ vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15)
+
+ '@vitest/expect@4.1.11':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.11
+ '@vitest/utils': 4.1.11
+ chai: 6.2.2
+ tinyrainbow: 3.1.1
+
+ '@vitest/mocker@4.1.11(msw@2.15.0(@types/node@24.13.6)(typescript@5.9.3))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15))':
+ dependencies:
+ '@vitest/spy': 4.1.11
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ msw: 2.15.0(@types/node@24.13.6)(typescript@5.9.3)
+ vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15)
+
+ '@vitest/pretty-format@4.1.11':
+ dependencies:
+ tinyrainbow: 3.1.1
+
+ '@vitest/runner@4.1.11':
+ dependencies:
+ '@vitest/utils': 4.1.11
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.11':
+ dependencies:
+ '@vitest/pretty-format': 4.1.11
+ '@vitest/utils': 4.1.11
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.11': {}
+
+ '@vitest/utils@4.1.11':
+ dependencies:
+ '@vitest/pretty-format': 4.1.11
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.1
+
+ '@whatwg-node/disposablestack@0.0.6':
+ dependencies:
+ '@whatwg-node/promise-helpers': 1.3.2
+ tslib: 2.8.1
+
+ '@whatwg-node/events@0.1.2':
+ dependencies:
+ tslib: 2.8.1
+
+ '@whatwg-node/fetch@0.10.13':
+ dependencies:
+ '@whatwg-node/node-fetch': 0.8.6
+ urlpattern-polyfill: 10.1.0
+
+ '@whatwg-node/node-fetch@0.8.6':
+ dependencies:
+ '@fastify/busboy': 3.2.2
+ '@whatwg-node/disposablestack': 0.0.6
+ '@whatwg-node/promise-helpers': 1.3.2
+ tslib: 2.8.1
+
+ '@whatwg-node/promise-helpers@1.3.2':
+ dependencies:
+ tslib: 2.8.1
+
+ '@whatwg-node/server@0.11.0':
+ dependencies:
+ '@envelop/instrumentation': 1.0.1
+ '@whatwg-node/disposablestack': 0.0.6
+ '@whatwg-node/fetch': 0.10.13
+ '@whatwg-node/promise-helpers': 1.3.2
+ tslib: 2.8.1
+
+ '@wry/caches@1.0.1':
+ dependencies:
+ tslib: 2.8.1
+
+ '@wry/context@0.7.4':
+ dependencies:
+ tslib: 2.8.1
+
+ '@wry/equality@0.5.7':
+ dependencies:
+ tslib: 2.8.1
+
+ '@wry/trie@0.5.0':
+ dependencies:
+ tslib: 2.8.1
+
+ acorn-jsx@5.3.2(acorn@8.18.0):
+ dependencies:
+ acorn: 8.18.0
+
+ acorn@8.18.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
+
+ ansi-regex@5.0.1: {}
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
+ ansi-styles@5.2.0: {}
+
+ argparse@2.0.1: {}
+
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
+ aria-query@5.3.2: {}
+
+ array-buffer-byte-length@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ is-array-buffer: 3.0.5
+
+ array-includes@3.2.0:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+ es-shim-unscopables: 1.1.0
+ is-string: 1.1.1
+ math-intrinsics: 1.1.0
+
+ array.prototype.flat@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.flatmap@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-shim-unscopables: 1.1.0
+
+ arraybuffer.prototype.slice@1.0.4:
+ dependencies:
+ array-buffer-byte-length: 1.0.2
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ is-array-buffer: 3.0.5
+
+ assertion-error@2.0.1: {}
+
+ ast-types-flow@0.0.8: {}
+
+ async-function@1.0.0: {}
+
+ available-typed-arrays@1.0.7:
+ dependencies:
+ possible-typed-array-names: 1.1.0
+
+ axe-core@4.13.0: {}
+
+ axobject-query@4.1.0: {}
+
+ balanced-match@1.0.2: {}
+
+ balanced-match@4.0.4: {}
+
+ baseline-browser-mapping@2.11.25: {}
+
+ bidi-js@1.1.0:
+ dependencies:
+ require-from-string: 2.0.2
+
+ brace-expansion@1.1.21:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ brace-expansion@5.0.12:
+ dependencies:
+ balanced-match: 4.0.4
+
+ browserslist@4.29.0:
+ dependencies:
+ baseline-browser-mapping: 2.11.25
+ caniuse-lite: 1.0.30001810
+ electron-to-chromium: 1.5.438
+ node-releases: 2.0.57
+ update-browserslist-db: 1.3.3(browserslist@4.29.0)
+
+ buffer-from@1.1.2: {}
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ call-bind@1.0.9:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ get-intrinsic: 1.3.0
+ set-function-length: 1.2.2
+
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+
+ callsites@3.1.0: {}
+
+ caniuse-lite@1.0.30001810: {}
+
+ chai@6.2.2: {}
+
+ chalk@4.1.2:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
+ cli-width@4.1.0: {}
+
+ cliui@8.0.1:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 7.0.0
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ concat-map@0.0.1: {}
+
+ convert-source-map@2.0.0: {}
+
+ cookie@1.1.1: {}
+
+ cross-inspect@1.0.1:
+ dependencies:
+ tslib: 2.8.1
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ css-tree@3.2.1:
+ dependencies:
+ mdn-data: 2.27.1
+ source-map-js: 1.2.1
+
+ css.escape@1.5.1: {}
+
+ csstype@3.2.3: {}
+
+ damerau-levenshtein@1.0.8: {}
+
+ data-urls@7.0.0:
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ data-view-buffer@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ data-view-byte-length@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ data-view-byte-offset@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ dataloader@2.2.3: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ decimal.js@10.6.0: {}
+
+ deep-is@0.1.4: {}
+
+ define-data-property@1.1.4:
+ dependencies:
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ define-properties@1.2.1:
+ dependencies:
+ define-data-property: 1.1.4
+ has-property-descriptors: 1.0.2
+ object-keys: 1.1.1
+
+ dequal@2.0.3: {}
+
+ detect-libc@2.1.2: {}
+
+ dom-accessibility-api@0.5.16: {}
+
+ dom-accessibility-api@0.6.3: {}
+
+ dotenv@17.4.2: {}
+
+ drizzle-kit@0.31.11:
+ dependencies:
+ '@drizzle-team/brocli': 0.10.2
+ '@esbuild-kit/esm-loader': 2.6.5
+ esbuild: 0.25.12
+ tsx: 4.23.15
+
+ drizzle-orm@0.45.3(@types/pg@8.23.1)(pg@8.23.0):
+ optionalDependencies:
+ '@types/pg': 8.23.1
+ pg: 8.23.0
+
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ electron-to-chromium@1.5.438: {}
+
+ emoji-regex@8.0.0: {}
+
+ emoji-regex@9.2.2: {}
+
+ entities@8.1.0: {}
+
+ es-abstract-get@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ is-callable: 1.2.7
+ object-inspect: 1.13.4
+
+ es-abstract@1.24.2:
+ dependencies:
+ array-buffer-byte-length: 1.0.2
+ arraybuffer.prototype.slice: 1.0.4
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ data-view-buffer: 1.0.2
+ data-view-byte-length: 1.0.2
+ data-view-byte-offset: 1.0.1
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ es-set-tostringtag: 2.1.0
+ es-to-primitive: 1.3.4
+ function.prototype.name: 1.2.0
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ get-symbol-description: 1.1.0
+ globalthis: 1.0.4
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+ has-proto: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.4
+ internal-slot: 1.1.0
+ is-array-buffer: 3.0.5
+ is-callable: 1.2.7
+ is-data-view: 1.0.2
+ is-negative-zero: 2.0.3
+ is-regex: 1.2.1
+ is-set: 2.0.3
+ is-shared-array-buffer: 1.0.4
+ is-string: 1.1.1
+ is-typed-array: 1.1.15
+ is-weakref: 1.1.1
+ math-intrinsics: 1.1.0
+ object-inspect: 1.13.4
+ object-keys: 1.1.1
+ object.assign: 4.1.7
+ own-keys: 1.0.2
+ regexp.prototype.flags: 1.5.4
+ safe-array-concat: 1.1.4
+ safe-push-apply: 1.0.0
+ safe-regex-test: 1.1.0
+ set-proto: 1.0.0
+ stop-iteration-iterator: 1.1.0
+ string.prototype.trim: 1.2.11
+ string.prototype.trimend: 1.0.10
+ string.prototype.trimstart: 1.0.8
+ typed-array-buffer: 1.0.3
+ typed-array-byte-length: 1.0.3
+ typed-array-byte-offset: 1.0.5
+ typed-array-length: 1.0.8
+ unbox-primitive: 1.1.0
+ which-typed-array: 1.1.24
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-module-lexer@2.3.2: {}
+
+ es-object-atoms@1.1.2:
+ dependencies:
+ es-errors: 1.3.0
+
+ es-set-tostringtag@2.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.4
+
+ es-shim-unscopables@1.1.0:
+ dependencies:
+ hasown: 2.0.4
+
+ es-to-primitive@1.3.4:
+ dependencies:
+ es-abstract-get: 1.0.0
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ is-callable: 1.2.7
+ is-date-object: 1.1.0
+ is-symbol: 1.1.1
+
+ 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.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.28.2
+ '@esbuild/android-arm': 0.28.2
+ '@esbuild/android-arm64': 0.28.2
+ '@esbuild/android-x64': 0.28.2
+ '@esbuild/darwin-arm64': 0.28.2
+ '@esbuild/darwin-x64': 0.28.2
+ '@esbuild/freebsd-arm64': 0.28.2
+ '@esbuild/freebsd-x64': 0.28.2
+ '@esbuild/linux-arm': 0.28.2
+ '@esbuild/linux-arm64': 0.28.2
+ '@esbuild/linux-ia32': 0.28.2
+ '@esbuild/linux-loong64': 0.28.2
+ '@esbuild/linux-mips64el': 0.28.2
+ '@esbuild/linux-ppc64': 0.28.2
+ '@esbuild/linux-riscv64': 0.28.2
+ '@esbuild/linux-s390x': 0.28.2
+ '@esbuild/linux-x64': 0.28.2
+ '@esbuild/netbsd-arm64': 0.28.2
+ '@esbuild/netbsd-x64': 0.28.2
+ '@esbuild/openbsd-arm64': 0.28.2
+ '@esbuild/openbsd-x64': 0.28.2
+ '@esbuild/openharmony-arm64': 0.28.2
+ '@esbuild/sunos-x64': 0.28.2
+ '@esbuild/win32-arm64': 0.28.2
+ '@esbuild/win32-ia32': 0.28.2
+ '@esbuild/win32-x64': 0.28.2
+
+ escalade@3.2.0: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ eslint-config-prettier@10.1.8(eslint@9.39.5):
+ dependencies:
+ eslint: 9.39.5
+
+ eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5):
+ dependencies:
+ aria-query: 5.3.2
+ array-includes: 3.2.0
+ array.prototype.flatmap: 1.3.3
+ ast-types-flow: 0.0.8
+ axe-core: 4.13.0
+ axobject-query: 4.1.0
+ damerau-levenshtein: 1.0.8
+ emoji-regex: 9.2.2
+ eslint: 9.39.5
+ hasown: 2.0.4
+ jsx-ast-utils: 3.3.5
+ language-tags: 1.0.9
+ minimatch: 3.1.5
+ object.fromentries: 2.0.8
+ safe-regex-test: 1.1.0
+ string.prototype.includes: 2.0.1
+
+ eslint-plugin-react-hooks@7.1.1(eslint@9.39.5):
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/parser': 7.29.9
+ eslint: 9.39.5
+ hermes-parser: 0.25.1
+ zod: 4.6.5
+ zod-validation-error: 4.0.2(zod@4.6.5)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-react-refresh@0.5.7(eslint@9.39.5):
+ dependencies:
+ eslint: 9.39.5
+
+ eslint-scope@8.4.0:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@4.2.1: {}
+
+ eslint-visitor-keys@5.0.1: {}
+
+ eslint@9.39.5:
+ dependencies:
+ '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5)
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.21.2
+ '@eslint/config-helpers': 0.4.2
+ '@eslint/core': 0.17.0
+ '@eslint/eslintrc': 3.3.7
+ '@eslint/js': 9.39.5
+ '@eslint/plugin-kit': 0.4.1
+ '@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
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.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
+ lodash.merge: 4.6.2
+ minimatch: 3.1.5
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@10.4.0:
+ dependencies:
+ acorn: 8.18.0
+ acorn-jsx: 5.3.2(acorn@8.18.0)
+ eslint-visitor-keys: 4.2.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.4.0: {}
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
+ fast-string-truncated-width@3.0.3: {}
+
+ fast-string-width@3.0.2:
+ dependencies:
+ fast-string-truncated-width: 3.0.3
+
+ fast-wrap-ansi@0.2.2:
+ dependencies:
+ fast-string-width: 3.0.2
+
+ fdir@6.5.0(picomatch@4.0.7):
+ optionalDependencies:
+ picomatch: 4.0.7
+
+ 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.4
+ keyv: 4.5.4
+
+ flatted@3.4.4: {}
+
+ for-each@0.3.5:
+ dependencies:
+ is-callable: 1.2.7
+
+ fsevents@2.3.3:
+ optional: true
+
+ function-bind@1.1.2: {}
+
+ function.prototype.name@1.2.0:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.2
+ hasown: 2.0.4
+ is-callable: 1.2.7
+ is-document.all: 1.0.0
+
+ functions-have-names@1.2.3: {}
+
+ generator-function@2.0.1: {}
+
+ gensync@1.0.0-beta.2: {}
+
+ get-caller-file@2.0.5: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.4
+ math-intrinsics: 1.1.0
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.2
+
+ get-symbol-description@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+
+ get-tsconfig@4.14.3:
+ dependencies:
+ resolve-pkg-maps: 1.0.0
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ globals@14.0.0: {}
+
+ globals@17.12.0: {}
+
+ globalthis@1.0.4:
+ dependencies:
+ define-properties: 1.2.1
+ gopd: 1.2.0
+
+ gopd@1.2.0: {}
+
+ graphql-tag@2.12.7(graphql@16.14.2):
+ dependencies:
+ graphql: 16.14.2
+ tslib: 2.8.1
+
+ graphql-yoga@5.24.1(graphql@16.14.2):
+ dependencies:
+ '@envelop/core': 5.6.1
+ '@envelop/instrumentation': 1.0.1
+ '@graphql-tools/executor': 2.0.1(graphql@16.14.2)
+ '@graphql-tools/schema': 10.1.1(graphql@16.14.2)
+ '@graphql-tools/utils': 11.2.2(graphql@16.14.2)
+ '@graphql-yoga/logger': 2.0.2
+ '@graphql-yoga/subscription': 5.1.1
+ '@whatwg-node/fetch': 0.10.13
+ '@whatwg-node/promise-helpers': 1.3.2
+ '@whatwg-node/server': 0.11.0
+ graphql: 16.14.2
+ lru-cache: 10.4.3
+ tslib: 2.8.1
+
+ graphql@16.14.2: {}
+
+ has-bigints@1.1.0: {}
+
+ has-flag@4.0.0: {}
+
+ has-property-descriptors@1.0.2:
+ dependencies:
+ es-define-property: 1.0.1
+
+ has-proto@1.2.0:
+ dependencies:
+ dunder-proto: 1.0.1
+
+ has-symbols@1.1.0: {}
+
+ has-tostringtag@1.0.2:
+ dependencies:
+ has-symbols: 1.1.0
+
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
+ headers-polyfill@5.0.1:
+ dependencies:
+ '@types/set-cookie-parser': 2.4.10
+ set-cookie-parser: 3.1.2
+
+ hermes-estree@0.25.1: {}
+
+ hermes-parser@0.25.1:
+ dependencies:
+ hermes-estree: 0.25.1
+
+ html-encoding-sniffer@6.0.0:
+ dependencies:
+ '@exodus/bytes': 1.16.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ ignore@5.3.2: {}
+
+ ignore@7.0.10: {}
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ imurmurhash@0.1.4: {}
+
+ indent-string@4.0.0: {}
+
+ internal-slot@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ hasown: 2.0.4
+ side-channel: 1.1.1
+
+ is-array-buffer@3.0.5:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+ is-async-function@2.1.1:
+ dependencies:
+ async-function: 1.0.0
+ call-bound: 1.0.4
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-bigint@1.1.0:
+ dependencies:
+ has-bigints: 1.1.0
+
+ is-boolean-object@1.2.2:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-callable@1.2.7: {}
+
+ is-data-view@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ is-typed-array: 1.1.15
+
+ is-date-object@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-document.all@1.0.0:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-extglob@2.1.1: {}
+
+ is-finalizationregistry@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-fullwidth-code-point@3.0.0: {}
+
+ is-generator-function@1.1.2:
+ dependencies:
+ call-bound: 1.0.4
+ generator-function: 2.0.1
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ is-map@2.0.3: {}
+
+ is-negative-zero@2.0.3: {}
+
+ is-node-process@1.2.0: {}
+
+ is-number-object@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-potential-custom-element-name@1.0.1: {}
+
+ is-regex@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.4
+
+ is-set@2.0.3: {}
+
+ is-shared-array-buffer@1.0.4:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-string@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-symbol@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-symbols: 1.1.0
+ safe-regex-test: 1.1.0
+
+ is-typed-array@1.1.15:
+ dependencies:
+ which-typed-array: 1.1.24
+
+ is-weakmap@2.0.2: {}
+
+ is-weakref@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-weakset@2.0.4:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+ isarray@2.0.5: {}
+
+ isexe@2.0.0: {}
+
+ js-tokens@4.0.0: {}
+
+ js-yaml@4.3.2:
+ dependencies:
+ argparse: 2.0.1
+
+ jsdom@29.1.1:
+ dependencies:
+ '@asamuzakjp/css-color': 5.1.11
+ '@asamuzakjp/dom-selector': 7.1.1
+ '@bramus/specificity': 2.4.2
+ '@csstools/css-syntax-patches-for-csstree': 1.1.14(css-tree@3.2.1)
+ '@exodus/bytes': 1.16.0
+ css-tree: 3.2.1
+ data-urls: 7.0.0
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 6.0.0
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.5.3
+ parse5: 8.0.1
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.2
+ undici: 7.29.1
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ jsesc@3.1.0: {}
+
+ json-buffer@3.0.1: {}
+
+ json-schema-traverse@0.4.1: {}
+
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
+ json5@2.2.3: {}
+
+ jsx-ast-utils@3.3.5:
+ dependencies:
+ array-includes: 3.2.0
+ array.prototype.flat: 1.3.3
+ object.assign: 4.1.7
+ object.values: 1.2.1
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
+ language-subtag-registry@0.3.23: {}
+
+ language-tags@1.0.9:
+ dependencies:
+ language-subtag-registry: 0.3.23
+
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
+ lightningcss-android-arm64@1.33.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.33.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.33.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.33.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.33.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.33.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.33.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.33.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.33.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.33.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.33.0:
+ optional: true
+
+ lightningcss@1.33.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.33.0
+ lightningcss-darwin-arm64: 1.33.0
+ lightningcss-darwin-x64: 1.33.0
+ lightningcss-freebsd-x64: 1.33.0
+ lightningcss-linux-arm-gnueabihf: 1.33.0
+ lightningcss-linux-arm64-gnu: 1.33.0
+ lightningcss-linux-arm64-musl: 1.33.0
+ lightningcss-linux-x64-gnu: 1.33.0
+ lightningcss-linux-x64-musl: 1.33.0
+ lightningcss-win32-arm64-msvc: 1.33.0
+ lightningcss-win32-x64-msvc: 1.33.0
+
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lodash.merge@4.6.2: {}
+
+ lru-cache@10.4.3: {}
+
+ lru-cache@11.5.3: {}
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ lz-string@1.5.0: {}
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ math-intrinsics@1.1.0: {}
+
+ mdn-data@2.27.1: {}
+
+ min-indent@1.0.1: {}
+
+ minimatch@10.2.6:
+ dependencies:
+ brace-expansion: 5.0.12
+
+ minimatch@3.1.5:
+ dependencies:
+ brace-expansion: 1.1.21
+
+ ms@2.1.3: {}
+
+ msw@2.15.0(@types/node@24.13.6)(typescript@5.9.3):
+ dependencies:
+ '@inquirer/confirm': 6.3.2(@types/node@24.13.6)
+ '@mswjs/interceptors': 0.41.9
+ '@open-draft/deferred-promise': 3.0.0
+ '@types/statuses': 2.0.6
+ cookie: 1.1.1
+ graphql: 16.14.2
+ headers-polyfill: 5.0.1
+ is-node-process: 1.2.0
+ outvariant: 1.4.3
+ path-to-regexp: 6.3.0
+ picocolors: 1.1.1
+ rettime: 0.11.11
+ statuses: 2.0.2
+ strict-event-emitter: 0.5.1
+ tough-cookie: 6.0.2
+ type-fest: 5.10.0
+ until-async: 3.0.2
+ yargs: 17.7.3
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - '@types/node'
+
+ mute-stream@3.0.0: {}
+
+ nanoid@3.3.19: {}
+
+ natural-compare@1.4.0: {}
+
+ node-releases@2.0.57: {}
+
+ object-inspect@1.13.4: {}
+
+ object-keys@1.1.1: {}
+
+ object.assign@4.1.7:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+ has-symbols: 1.1.0
+ object-keys: 1.1.1
+
+ object.fromentries@2.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+
+ object.values@1.2.1:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ obug@2.2.1: {}
+
+ optimism@0.18.1:
+ dependencies:
+ '@wry/caches': 1.0.1
+ '@wry/context': 0.7.4
+ '@wry/trie': 0.5.0
+ tslib: 2.8.1
+
+ 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
+
+ outvariant@1.4.3: {}
+
+ own-keys@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ object-keys: 1.1.1
+ safe-push-apply: 1.0.0
+
+ p-limit@3.1.0:
+ dependencies:
+ yocto-queue: 0.1.0
+
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ parse5@8.0.1:
+ dependencies:
+ entities: 8.1.0
+
+ path-exists@4.0.0: {}
+
+ path-key@3.1.1: {}
+
+ path-to-regexp@6.3.0: {}
+
+ pathe@2.0.3: {}
+
+ pg-cloudflare@1.4.0:
+ optional: true
+
+ pg-connection-string@2.14.0: {}
+
+ pg-int8@1.0.1: {}
+
+ pg-pool@3.14.0(pg@8.23.0):
+ dependencies:
+ pg: 8.23.0
+
+ pg-protocol@1.16.0: {}
+
+ pg-types@2.2.0:
+ dependencies:
+ pg-int8: 1.0.1
+ postgres-array: 2.0.0
+ postgres-bytea: 1.0.1
+ postgres-date: 1.0.7
+ postgres-interval: 1.2.0
+
+ pg@8.23.0:
+ dependencies:
+ pg-connection-string: 2.14.0
+ pg-pool: 3.14.0(pg@8.23.0)
+ pg-protocol: 1.16.0
+ pg-types: 2.2.0
+ pgpass: 1.0.5
+ optionalDependencies:
+ pg-cloudflare: 1.4.0
+
+ pgpass@1.0.5:
+ dependencies:
+ split2: 4.2.0
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.7: {}
+
+ playwright-core@1.63.0: {}
+
+ playwright@1.63.0:
+ dependencies:
+ playwright-core: 1.63.0
+
+ possible-typed-array-names@1.1.0: {}
+
+ postcss@8.5.28:
+ dependencies:
+ nanoid: 3.3.19
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ postgres-array@2.0.0: {}
+
+ postgres-bytea@1.0.1: {}
+
+ postgres-date@1.0.7: {}
+
+ postgres-interval@1.2.0:
+ dependencies:
+ xtend: 4.0.2
+
+ prelude-ls@1.2.1: {}
+
+ prettier@3.9.9: {}
+
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
+ punycode@2.3.1: {}
+
+ react-dom@19.3.0(react@19.3.0):
+ dependencies:
+ react: 19.3.0
+ scheduler: 0.28.0
+
+ react-is@17.0.2: {}
+
+ react-router-dom@7.18.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+ dependencies:
+ react: 19.3.0
+ react-dom: 19.3.0(react@19.3.0)
+ react-router: 7.18.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+
+ react-router@7.18.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+ dependencies:
+ cookie: 1.1.1
+ react: 19.3.0
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 19.3.0(react@19.3.0)
+
+ react@19.3.0: {}
+
+ redent@3.0.0:
+ dependencies:
+ indent-string: 4.0.0
+ strip-indent: 3.0.0
+
+ reflect.getprototypeof@1.0.10:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ which-builtin-type: 1.2.1
+
+ regexp.prototype.flags@1.5.4:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-errors: 1.3.0
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ set-function-name: 2.0.2
+
+ require-directory@2.1.1: {}
+
+ require-from-string@2.0.2: {}
+
+ resolve-from@4.0.0: {}
+
+ resolve-pkg-maps@1.0.0: {}
+
+ rettime@0.11.11: {}
+
+ rolldown@1.2.10:
+ dependencies:
+ '@oxc-project/types': 0.151.0
+ '@rolldown/pluginutils': 1.0.1
+ optionalDependencies:
+ '@rolldown/binding-android-arm-eabi': 1.2.10
+ '@rolldown/binding-android-arm64': 1.2.10
+ '@rolldown/binding-darwin-arm64': 1.2.10
+ '@rolldown/binding-darwin-x64': 1.2.10
+ '@rolldown/binding-freebsd-x64': 1.2.10
+ '@rolldown/binding-linux-arm-gnueabihf': 1.2.10
+ '@rolldown/binding-linux-arm64-gnu': 1.2.10
+ '@rolldown/binding-linux-arm64-musl': 1.2.10
+ '@rolldown/binding-linux-ppc64-gnu': 1.2.10
+ '@rolldown/binding-linux-s390x-gnu': 1.2.10
+ '@rolldown/binding-linux-x64-gnu': 1.2.10
+ '@rolldown/binding-linux-x64-musl': 1.2.10
+ '@rolldown/binding-openharmony-arm64': 1.2.10
+ '@rolldown/binding-win32-arm64-msvc': 1.2.10
+ '@rolldown/binding-win32-x64-msvc': 1.2.10
+
+ rxjs@7.8.2:
+ dependencies:
+ tslib: 2.8.1
+
+ safe-array-concat@1.1.4:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ has-symbols: 1.1.0
+ isarray: 2.0.5
+
+ safe-push-apply@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ isarray: 2.0.5
+
+ safe-regex-test@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-regex: 1.2.1
+
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
+ scheduler@0.28.0: {}
+
+ semver@6.3.1: {}
+
+ semver@7.8.5: {}
+
+ set-cookie-parser@2.7.2: {}
+
+ set-cookie-parser@3.1.2: {}
+
+ set-function-length@1.2.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+
+ set-function-name@2.0.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.2
+
+ set-proto@1.0.0:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ side-channel-list@1.0.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-map@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-weakmap@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-map: 1.0.1
+
+ side-channel@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.1
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
+ siginfo@2.0.0: {}
+
+ signal-exit@4.1.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: {}
+
+ split2@4.2.0: {}
+
+ stackback@0.0.2: {}
+
+ statuses@2.0.2: {}
+
+ std-env@4.2.0: {}
+
+ stop-iteration-iterator@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ internal-slot: 1.1.0
+
+ strict-event-emitter@0.5.1: {}
+
+ string-width@4.2.3:
+ dependencies:
+ emoji-regex: 8.0.0
+ is-fullwidth-code-point: 3.0.0
+ strip-ansi: 6.0.1
+
+ string.prototype.includes@2.0.1:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+
+ string.prototype.trim@1.2.11:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-data-property: 1.1.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+ has-property-descriptors: 1.0.2
+ safe-regex-test: 1.1.0
+
+ string.prototype.trimend@1.0.10:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ string.prototype.trimstart@1.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ strip-ansi@6.0.1:
+ dependencies:
+ ansi-regex: 5.0.1
+
+ strip-indent@3.0.0:
+ dependencies:
+ min-indent: 1.0.1
+
+ strip-json-comments@3.1.1: {}
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ symbol-tree@3.2.4: {}
+
+ tagged-tag@1.0.0: {}
+
+ tinybench@2.9.0: {}
+
+ tinyexec@1.3.1: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+
+ tinyrainbow@3.1.1: {}
+
+ tldts-core@7.4.14: {}
+
+ tldts@7.4.14:
+ dependencies:
+ tldts-core: 7.4.14
+
+ tough-cookie@6.0.2:
+ dependencies:
+ tldts: 7.4.14
+
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
+ ts-api-utils@2.5.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
+ tslib@2.8.1: {}
+
+ tsx@4.23.15:
+ dependencies:
+ esbuild: 0.28.2
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
+ type-fest@5.10.0:
+ dependencies:
+ tagged-tag: 1.0.0
+
+ typed-array-buffer@1.0.3:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-typed-array: 1.1.15
+
+ typed-array-byte-length@1.0.3:
+ dependencies:
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ has-proto: 1.2.0
+ is-typed-array: 1.1.15
+
+ typed-array-byte-offset@1.0.5:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ is-typed-array: 1.1.15
+ reflect.getprototypeof: 1.0.10
+
+ typed-array-length@1.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ is-typed-array: 1.1.15
+ possible-typed-array-names: 1.1.0
+ reflect.getprototypeof: 1.0.10
+
+ typescript-eslint@8.70.1(eslint@9.39.5)(typescript@5.9.3):
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.70.1(eslint@9.39.5)(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.70.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.70.1(eslint@9.39.5)(typescript@5.9.3)
+ eslint: 9.39.5
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ typescript@5.9.3: {}
+
+ unbox-primitive@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-bigints: 1.1.0
+ has-symbols: 1.1.0
+ which-boxed-primitive: 1.1.1
+
+ undici-types@7.18.2: {}
+
+ undici@7.29.1: {}
+
+ until-async@3.0.2: {}
+
+ update-browserslist-db@1.3.3(browserslist@4.29.0):
+ dependencies:
+ browserslist: 4.29.0
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
+ urlpattern-polyfill@10.1.0: {}
+
+ vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15):
+ dependencies:
+ lightningcss: 1.33.0
+ picomatch: 4.0.7
+ postcss: 8.5.28
+ rolldown: 1.2.10
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 24.13.6
+ esbuild: 0.28.2
+ fsevents: 2.3.3
+ tsx: 4.23.15
+
+ vitest@4.1.11(@types/node@24.13.6)(jsdom@29.1.1)(msw@2.15.0(@types/node@24.13.6)(typescript@5.9.3))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15)):
+ dependencies:
+ '@vitest/expect': 4.1.11
+ '@vitest/mocker': 4.1.11(msw@2.15.0(@types/node@24.13.6)(typescript@5.9.3))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15))
+ '@vitest/pretty-format': 4.1.11
+ '@vitest/runner': 4.1.11
+ '@vitest/snapshot': 4.1.11
+ '@vitest/spy': 4.1.11
+ '@vitest/utils': 4.1.11
+ es-module-lexer: 2.3.2
+ expect-type: 1.4.0
+ magic-string: 0.30.21
+ obug: 2.2.1
+ pathe: 2.0.3
+ picomatch: 4.0.7
+ std-env: 4.2.0
+ tinybench: 2.9.0
+ tinyexec: 1.3.1
+ tinyglobby: 0.2.17
+ tinyrainbow: 3.1.1
+ vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 24.13.6
+ jsdom: 29.1.1
+ transitivePeerDependencies:
+ - msw
+
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+
+ webidl-conversions@8.0.1: {}
+
+ whatwg-mimetype@5.0.0: {}
+
+ whatwg-url@16.0.1:
+ dependencies:
+ '@exodus/bytes': 1.16.0
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ which-boxed-primitive@1.1.1:
+ dependencies:
+ is-bigint: 1.1.0
+ is-boolean-object: 1.2.2
+ is-number-object: 1.1.1
+ is-string: 1.1.1
+ is-symbol: 1.1.1
+
+ which-builtin-type@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ function.prototype.name: 1.2.0
+ has-tostringtag: 1.0.2
+ is-async-function: 2.1.1
+ is-date-object: 1.1.0
+ is-finalizationregistry: 1.1.1
+ is-generator-function: 1.1.2
+ is-regex: 1.2.1
+ is-weakref: 1.1.1
+ isarray: 2.0.5
+ which-boxed-primitive: 1.1.1
+ which-collection: 1.0.2
+ which-typed-array: 1.1.24
+
+ which-collection@1.0.2:
+ dependencies:
+ is-map: 2.0.3
+ is-set: 2.0.3
+ is-weakmap: 2.0.2
+ is-weakset: 2.0.4
+
+ which-typed-array@1.1.24:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ for-each: 0.3.5
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+
+ 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: {}
+
+ wrap-ansi@7.0.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
+ xml-name-validator@5.0.0: {}
+
+ xmlchars@2.2.0: {}
+
+ xtend@4.0.2: {}
+
+ y18n@5.0.8: {}
+
+ yallist@3.1.1: {}
+
+ yargs-parser@21.1.1: {}
+
+ yargs@17.7.3:
+ dependencies:
+ cliui: 8.0.1
+ escalade: 3.2.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ string-width: 4.2.3
+ y18n: 5.0.8
+ yargs-parser: 21.1.1
+
+ yocto-queue@0.1.0: {}
+
+ zod-validation-error@4.0.2(zod@4.6.5):
+ dependencies:
+ zod: 4.6.5
+
+ zod@4.6.5: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..e6725a5
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,4 @@
+packages:
+ - 'server'
+ - 'client'
+ - 'e2e'
diff --git a/scripts/crate-vm-bootstrap-2.sh b/scripts/crate-vm-bootstrap-2.sh
new file mode 100644
index 0000000..e48144a
--- /dev/null
+++ b/scripts/crate-vm-bootstrap-2.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+# Finish the VM bootstrap after the first script stopped on a group-name
+# mismatch. Run with: sudo bash ~/crate-vm-bootstrap-2.sh
+#
+# Assumes PostgreSQL is installed and the t19 role + crate database already
+# exist (they were created by the first script).
+
+set -euo pipefail
+
+APP_USER=erikhfj
+APP_GROUP=eall_s
+
+echo "==> 1/4 Connection string for the app user"
+if [ -f /root/crate-db.env ]; then
+ install -m 640 -o root -g "$APP_GROUP" /root/crate-db.env "/home/${APP_USER}/crate-db.env"
+ echo " wrote /home/${APP_USER}/crate-db.env (root:${APP_GROUP}, 640)"
+else
+ echo " /root/crate-db.env missing — did the first script create the role?"
+ exit 1
+fi
+
+echo "==> 2/4 App directory and Apache docroot"
+mkdir -p "/home/${APP_USER}/T19-Project-2"
+chown "${APP_USER}:${APP_GROUP}" "/home/${APP_USER}/T19-Project-2"
+mkdir -p /var/www/html/project2
+chown -R "${APP_USER}:www-data" /var/www/html/project2
+chmod -R 755 /var/www/html/project2
+
+echo "==> 3/4 systemd unit for the GraphQL API"
+# Resolve the actual node binary path (nvm layout).
+NODE_BIN="$(sudo -u "$APP_USER" bash -lc 'source ~/.nvm/nvm.sh >/dev/null 2>&1 && command -v node')"
+echo " node binary: ${NODE_BIN}"
+
+cat > /etc/systemd/system/crate-api.service < 4/4 Apache reverse proxy for /graphql"
+cat > /etc/apache2/conf-available/crate-graphql.conf <<'APACHE'
+ProxyPreserveHost On
+ProxyPass /graphql http://127.0.0.1:3001/graphql
+ProxyPassReverse /graphql http://127.0.0.1:3001/graphql
+APACHE
+a2enmod proxy proxy_http headers >/dev/null
+a2enconf crate-graphql >/dev/null
+systemctl reload apache2
+
+echo
+echo "Bootstrap part 2 complete."
+echo " next (as ${APP_USER}): cd ~/T19-Project-2 && pnpm install && pnpm db:migrate && pnpm db:seed"
+echo " then: sudo systemctl start crate-api"
diff --git a/scripts/crate-vm-bootstrap.sh b/scripts/crate-vm-bootstrap.sh
new file mode 100644
index 0000000..512ab92
--- /dev/null
+++ b/scripts/crate-vm-bootstrap.sh
@@ -0,0 +1,115 @@
+#!/usr/bin/env bash
+# One-shot VM bootstrap for Crate (IT2810 Project 2).
+# Run ONCE with: sudo bash /tmp/crate-vm-bootstrap.sh
+#
+# Everything here needs root. After this script finishes, the rest of the
+# setup (clone/rsync, pnpm install, migrate, seed, systemd start) is done as
+# the regular user — see docs/VM-setup.md.
+
+set -euo pipefail
+
+echo "==> 1/5 Installing PostgreSQL and Apache modules"
+apt-get update -y
+apt-get install -y postgresql postgresql-contrib apache2
+a2enmod proxy proxy_http headers >/dev/null
+
+echo "==> 2/5 Creating database role and database"
+# Idempotent: skip if the role already exists.
+if sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='t19'" | grep -q 1; then
+ echo " role t19 already exists"
+else
+ # Generate a password and store it in the deploy .env on disk (root-only).
+ DB_PASS="$(openssl rand -hex 16)"
+ sudo -u postgres psql -v ON_ERROR_STOP=1 < /root/crate-db.env </dev/null 2>&1; then
+ echo " (warning) user erikhfj not found — adjust ownership below manually"
+fi
+install -m 640 -o root -g erikhfj /root/crate-db.env /home/erikhfj/crate-db.env
+echo " connection string: /home/erikhfj/crate-db.env (root:erikhfj, 640)"
+
+echo "==> 3/5 Installing Node.js 24 via nvm (for user erikhfj)"
+sudo -u erikhfj bash <<'USERSCRIPT'
+set -euo pipefail
+touch ~/.bash_profile
+if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
+ curl -fsSL -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
+fi
+# shellcheck disable=SC1091
+. "$HOME/.nvm/nvm.sh"
+nvm install 24
+nvm alias default 24
+corepack enable
+corepack prepare pnpm@10.33.0 --activate
+node -v
+pnpm -v
+USERSCRIPT
+
+echo "==> 4/5 Preparing app directory and Apache docroot"
+mkdir -p /home/erikhfj/T19-Project-2
+chown erikhfj:erikhfj /home/erikhfj/T19-Project-2
+mkdir -p /var/www/html/project2
+chown -R erikhfj:www-data /var/www/html/project2
+chmod -R 755 /var/www/html/project2
+
+echo "==> 5/5 Writing systemd unit for the GraphQL API"
+cat > /etc/systemd/system/crate-api.service <<'UNIT'
+[Unit]
+Description=Crate GraphQL API (IT2810 Project 2)
+After=network.target postgresql.service
+Wants=postgresql.service
+
+[Service]
+Type=simple
+User=erikhfj
+Group=erikhfj
+WorkingDirectory=/home/erikhfj/T19-Project-2/server
+EnvironmentFile=/home/erikhfj/T19-Project-2/.env
+ExecStart=/home/erikhfj/.nvm/versions/node/v24.13.0/bin/node dist/index.js
+Restart=on-failure
+RestartSec=3
+
+[Install]
+WantedBy=multi-user.target
+UNIT
+
+echo "==> Enabling (but not starting) crate-api"
+systemctl daemon-reload
+systemctl enable crate-api
+
+# Apache reverse proxy for /graphql
+if [ ! -f /etc/apache2/conf-available/crate-graphql.conf ]; then
+ cat > /etc/apache2/conf-available/crate-graphql.conf <<'APACHE'
+ProxyPreserveHost On
+ProxyPass /graphql http://127.0.0.1:3001/graphql
+ProxyPassReverse /graphql http://127.0.0.1:3001/graphql
+APACHE
+ a2enconf crate-graphql >/dev/null
+fi
+systemctl reload apache2
+
+echo
+echo "All done."
+echo " DB connection string : /home/erikhfj/crate-db.env"
+echo " systemd unit : crate-api (enabled, not started)"
+echo " Apache /graphql : proxied to 127.0.0.1:3001"
+echo
+echo "Next (as erikhfj): copy the project into ~/T19-Project-2, then"
+echo " pnpm install && pnpm db:migrate && pnpm db:seed"
+echo " sudo systemctl start crate-api"
diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts
new file mode 100644
index 0000000..03136a6
--- /dev/null
+++ b/server/drizzle.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'drizzle-kit';
+
+export default defineConfig({
+ schema: './src/db/schema.ts',
+ out: './drizzle',
+ dialect: 'postgresql',
+ dbCredentials: {
+ url: process.env.DATABASE_URL ?? 'postgres://t19:t19@localhost:5432/crate',
+ },
+});
diff --git a/server/drizzle/0000_init.sql b/server/drizzle/0000_init.sql
new file mode 100644
index 0000000..4fe02bc
--- /dev/null
+++ b/server/drizzle/0000_init.sql
@@ -0,0 +1,111 @@
+-- Crate initial schema: catalog tables, user-generated data, and Postgres
+-- full-text search. FTS + GIN is what keeps search fast as the catalog grows.
+
+CREATE TABLE artist (
+ id serial PRIMARY KEY,
+ name text NOT NULL,
+ country varchar(2),
+ formed_year integer
+);
+
+CREATE TABLE album (
+ id serial PRIMARY KEY,
+ title text NOT NULL,
+ artist_id integer NOT NULL REFERENCES artist (id) ON DELETE CASCADE,
+ year integer,
+ cover_url text,
+ search_vector tsvector
+);
+
+CREATE INDEX album_search_vector_idx ON album USING gin (search_vector);
+CREATE INDEX album_title_idx ON album (title);
+CREATE INDEX album_year_idx ON album (year);
+CREATE INDEX album_artist_idx ON album (artist_id);
+
+CREATE TABLE genre (
+ id serial PRIMARY KEY,
+ name text NOT NULL UNIQUE
+);
+
+CREATE TABLE album_genre (
+ album_id integer NOT NULL REFERENCES album (id) ON DELETE CASCADE,
+ genre_id integer NOT NULL REFERENCES genre (id) ON DELETE CASCADE,
+ PRIMARY KEY (album_id, genre_id)
+);
+
+CREATE INDEX album_genre_genre_idx ON album_genre (genre_id);
+
+CREATE TABLE track (
+ id serial PRIMARY KEY,
+ album_id integer NOT NULL REFERENCES album (id) ON DELETE CASCADE,
+ number integer NOT NULL,
+ title text NOT NULL,
+ duration_seconds integer
+);
+
+CREATE INDEX track_album_idx ON track (album_id);
+
+CREATE TABLE "user" (
+ id varchar(36) PRIMARY KEY,
+ display_name text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE review (
+ id serial PRIMARY KEY,
+ user_id varchar(36) NOT NULL REFERENCES "user" (id) ON DELETE CASCADE,
+ album_id integer NOT NULL REFERENCES album (id) ON DELETE CASCADE,
+ rating integer NOT NULL CHECK (rating BETWEEN 1 AND 5),
+ text text,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE UNIQUE INDEX review_user_album_uidx ON review (user_id, album_id);
+CREATE INDEX review_album_idx ON review (album_id);
+
+CREATE TABLE crate_item (
+ user_id varchar(36) NOT NULL REFERENCES "user" (id) ON DELETE CASCADE,
+ album_id integer NOT NULL REFERENCES album (id) ON DELETE CASCADE,
+ added_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (user_id, album_id)
+);
+
+CREATE INDEX crate_user_idx ON crate_item (user_id);
+
+-- ---------------------------------------------------------------------------
+-- Full-text search document
+--
+-- The search vector is a denormalised document over the words a user is likely
+-- to type: album title and artist name. A trigger keeps it in sync so the
+-- application never has to. Using the 'simple' configuration gives us
+-- case-insensitive matching without language-specific stemming, which is what
+-- the brief asks for ("søk på enkeltord, case insensitiv").
+-- ---------------------------------------------------------------------------
+
+CREATE OR REPLACE FUNCTION album_search_vector_update() RETURNS trigger AS $$
+DECLARE
+ artist_name text;
+BEGIN
+ SELECT name INTO artist_name FROM artist WHERE id = NEW.artist_id;
+ NEW.search_vector :=
+ setweight(to_tsvector('simple', coalesce(NEW.title, '')), 'A') ||
+ setweight(to_tsvector('simple', coalesce(artist_name, '')), 'B');
+ RETURN NEW;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER album_search_vector_trigger
+ BEFORE INSERT OR UPDATE OF title, artist_id ON album
+ FOR EACH ROW EXECUTE FUNCTION album_search_vector_update();
+
+-- Keep albums in sync when their artist is renamed.
+CREATE OR REPLACE FUNCTION artist_search_vector_refresh() RETURNS trigger AS $$
+BEGIN
+ UPDATE album SET artist_id = artist_id WHERE artist_id = NEW.id;
+ RETURN NEW;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER artist_search_vector_trigger
+ AFTER UPDATE OF name ON artist
+ FOR EACH ROW EXECUTE FUNCTION artist_search_vector_refresh();
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..e557b7f
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@t19/server",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "tsx watch src/index.ts",
+ "build": "tsc -p tsconfig.json && node -e \"require('fs').copyFileSync('src/schema.graphql','dist/schema.graphql')\"",
+ "start": "node dist/index.js",
+ "typecheck": "tsc -p tsconfig.json --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "db:generate": "drizzle-kit generate",
+ "db:migrate": "tsx src/db/migrate.ts",
+ "db:seed": "tsx src/db/seed/seed.ts",
+ "db:seed:large": "tsx src/db/seed/generate.ts",
+ "db:studio": "drizzle-kit studio"
+ },
+ "dependencies": {
+ "dataloader": "^2.2.3",
+ "dotenv": "^17.2.3",
+ "drizzle-orm": "^0.45.2",
+ "graphql": "^16.12.0",
+ "graphql-yoga": "^5.18.0",
+ "pg": "^8.16.3"
+ },
+ "devDependencies": {
+ "@types/node": "^24.13.3",
+ "@types/pg": "^8.15.6",
+ "drizzle-kit": "^0.31.11",
+ "tsx": "^4.20.6",
+ "typescript": "~5.9.3",
+ "vitest": "^4.1.11"
+ }
+}
diff --git a/server/src/db/client.ts b/server/src/db/client.ts
new file mode 100644
index 0000000..7520e3d
--- /dev/null
+++ b/server/src/db/client.ts
@@ -0,0 +1,18 @@
+import './env.js';
+import { requireEnv } from './env.js';
+import { drizzle } from 'drizzle-orm/node-postgres';
+import pg from 'pg';
+import * as schema from './schema.js';
+
+/**
+ * Single shared connection pool. `pg` handles reconnects; pool size is kept
+ * modest because the GraphQL server multiplexes requests through it anyway.
+ */
+export const pool = new pg.Pool({
+ connectionString: requireEnv('DATABASE_URL'),
+ max: 10,
+});
+
+export const db = drizzle(pool, { schema });
+
+export type Database = typeof db;
diff --git a/server/src/db/env.ts b/server/src/db/env.ts
new file mode 100644
index 0000000..669cc4d
--- /dev/null
+++ b/server/src/db/env.ts
@@ -0,0 +1,20 @@
+import { config } from 'dotenv';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const here = dirname(fileURLToPath(import.meta.url));
+
+// The workspace root is three levels up from server/src/db. Loading from there
+// means a single .env serves both `pnpm --filter @t19/server …` (cwd=server/)
+// and `systemctl start crate-api` (EnvironmentFile=…/T19-Project-2/.env).
+// quiet: dotenv's default banner is noisy and prints vendor promos.
+config({ path: join(here, '..', '..', '..', '.env'), quiet: true });
+config({ path: join(here, '..', '..', '.env'), quiet: true });
+
+export function requireEnv(name: string): string {
+ const value = process.env[name];
+ if (!value) {
+ throw new Error(`Missing required environment variable ${name}. See .env.example.`);
+ }
+ return value;
+}
diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts
new file mode 100644
index 0000000..382e7ce
--- /dev/null
+++ b/server/src/db/migrate.ts
@@ -0,0 +1,56 @@
+import { readdirSync, readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { pool } from './client.js';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+/**
+ * Applies every *.sql file in drizzle/ in filename order. A bookkeeping table
+ * records what has already been applied so the script is safe to re-run.
+ */
+async function migrate() {
+ const migrationsDir = join(__dirname, '..', '..', 'drizzle');
+ const files = readdirSync(migrationsDir)
+ .filter((f) => f.endsWith('.sql'))
+ .sort();
+
+ const client = await pool.connect();
+ try {
+ await client.query(`
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ filename text PRIMARY KEY,
+ applied_at timestamptz NOT NULL DEFAULT now()
+ );
+ `);
+
+ for (const file of files) {
+ const already = await client.query(
+ 'SELECT 1 FROM schema_migrations WHERE filename = $1',
+ [file],
+ );
+ if (already.rowCount) {
+ console.log(`Skip ${file} (already applied)`);
+ continue;
+ }
+ const sql = readFileSync(join(migrationsDir, file), 'utf8');
+ console.log(`Apply ${file}`);
+ await client.query('BEGIN');
+ await client.query(sql);
+ await client.query('INSERT INTO schema_migrations (filename) VALUES ($1)', [file]);
+ await client.query('COMMIT');
+ }
+ console.log('Migrations complete.');
+ } catch (err) {
+ await client.query('ROLLBACK').catch(() => {});
+ throw err;
+ } finally {
+ client.release();
+ await pool.end();
+ }
+}
+
+migrate().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts
new file mode 100644
index 0000000..473d9f6
--- /dev/null
+++ b/server/src/db/schema.ts
@@ -0,0 +1,139 @@
+import {
+ pgTable,
+ serial,
+ text,
+ integer,
+ timestamp,
+ varchar,
+ customType,
+ uniqueIndex,
+ index,
+ primaryKey,
+} from 'drizzle-orm/pg-core';
+
+/**
+ * tsvector column type. Postgres full-text search is what makes single-term,
+ * case-insensitive search scale: a GIN index turns "find every album matching
+ * this word" into an index lookup instead of a sequential scan.
+ */
+const tsvector = customType<{ data: string }>({ dataType: () => 'tsvector' });
+
+export const artist = pgTable('artist', {
+ id: serial('id').primaryKey(),
+ name: text('name').notNull(),
+ country: varchar('country', { length: 2 }),
+ formedYear: integer('formed_year'),
+});
+
+export const album = pgTable(
+ 'album',
+ {
+ id: serial('id').primaryKey(),
+ title: text('title').notNull(),
+ artistId: integer('artist_id')
+ .notNull()
+ .references(() => artist.id),
+ year: integer('year'),
+ coverUrl: text('cover_url'),
+ /**
+ * Denormalised document of the words we search over (album title + artist
+ * name). Generated and kept up to date by a trigger in the migration so the
+ * application never has to maintain it.
+ */
+ searchVector: tsvector('search_vector'),
+ },
+ (t) => [
+ index('album_search_vector_idx').using('gin', t.searchVector),
+ index('album_title_idx').on(t.title),
+ index('album_year_idx').on(t.year),
+ index('album_artist_idx').on(t.artistId),
+ ],
+);
+
+export const genre = pgTable('genre', {
+ id: serial('id').primaryKey(),
+ name: text('name').notNull().unique(),
+});
+
+export const albumGenre = pgTable(
+ 'album_genre',
+ {
+ albumId: integer('album_id')
+ .notNull()
+ .references(() => album.id, { onDelete: 'cascade' }),
+ genreId: integer('genre_id')
+ .notNull()
+ .references(() => genre.id, { onDelete: 'cascade' }),
+ },
+ (t) => [primaryKey({ columns: [t.albumId, t.genreId] })],
+);
+
+export const track = pgTable(
+ 'track',
+ {
+ id: serial('id').primaryKey(),
+ albumId: integer('album_id')
+ .notNull()
+ .references(() => album.id, { onDelete: 'cascade' }),
+ number: integer('number').notNull(),
+ title: text('title').notNull(),
+ durationSeconds: integer('duration_seconds'),
+ },
+ (t) => [index('track_album_idx').on(t.albumId)],
+);
+
+/**
+ * Users are simulated: the client generates a UUID on first visit and stores it
+ * in localStorage (explicitly allowed by the lecture). No password, no session.
+ * That is enough to persist reviews and the crate on the server.
+ */
+export const user = pgTable('user', {
+ id: varchar('id', { length: 36 }).primaryKey(),
+ displayName: text('display_name').notNull(),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+});
+
+export const review = pgTable(
+ 'review',
+ {
+ id: serial('id').primaryKey(),
+ userId: varchar('user_id', { length: 36 })
+ .notNull()
+ .references(() => user.id, { onDelete: 'cascade' }),
+ albumId: integer('album_id')
+ .notNull()
+ .references(() => album.id, { onDelete: 'cascade' }),
+ rating: integer('rating').notNull(),
+ text: text('text'),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ },
+ (t) => [
+ uniqueIndex('review_user_album_uidx').on(t.userId, t.albumId),
+ index('review_album_idx').on(t.albumId),
+ ],
+);
+
+export const crateItem = pgTable(
+ 'crate_item',
+ {
+ userId: varchar('user_id', { length: 36 })
+ .notNull()
+ .references(() => user.id, { onDelete: 'cascade' }),
+ albumId: integer('album_id')
+ .notNull()
+ .references(() => album.id, { onDelete: 'cascade' }),
+ addedAt: timestamp('added_at').defaultNow().notNull(),
+ },
+ (t) => [
+ primaryKey({ columns: [t.userId, t.albumId] }),
+ index('crate_user_idx').on(t.userId),
+ ],
+);
+
+// Row types inferred from the schema, used by resolvers and loaders.
+export type ArtistRow = typeof artist.$inferSelect;
+export type AlbumRow = typeof album.$inferSelect;
+export type TrackRow = typeof track.$inferSelect;
+export type GenreRow = typeof genre.$inferSelect;
+export type UserRow = typeof user.$inferSelect;
+export type ReviewRow = typeof review.$inferSelect;
diff --git a/server/src/db/seed/generate.ts b/server/src/db/seed/generate.ts
new file mode 100644
index 0000000..d90d5e9
--- /dev/null
+++ b/server/src/db/seed/generate.ts
@@ -0,0 +1,189 @@
+/**
+ * Generates a large synthetic catalog on top of the realistic seed data.
+ * This is how we demonstrate the "udefinert stor datasamling" requirement:
+ * the same UI and the same GraphQL queries work whether the table has 20 rows
+ * or 1,000,000, because paging and filtering happen in SQL against indexes.
+ *
+ * pnpm db:seed:large # 100_000 albums
+ * ALBUM_COUNT=500000 pnpm db:seed:large
+ *
+ * Data is inserted in batches inside transactions so a 1M-row seed does not
+ * take all day or blow up Node's memory.
+ */
+import { db, pool } from '../client.js';
+import { artist, album, genre, albumGenre, track } from '../schema.js';
+import { sql } from 'drizzle-orm';
+
+const ALBUM_COUNT = Number(process.env.ALBUM_COUNT ?? 100_000);
+const BATCH = 1_000;
+
+const ADJECTIVES = [
+ 'Silent',
+ 'Electric',
+ 'Golden',
+ 'Broken',
+ 'Neon',
+ 'Hidden',
+ 'Rusted',
+ 'Velvet',
+ 'Crystal',
+ 'Midnight',
+ 'Scarlet',
+ 'Hollow',
+ 'Radiant',
+ 'Faded',
+ 'Wild',
+];
+const NOUNS = [
+ 'Horizon',
+ 'Echo',
+ 'Mirror',
+ 'Garden',
+ 'Machine',
+ 'River',
+ 'Signal',
+ 'Empire',
+ 'Season',
+ 'Letter',
+ 'Cathedral',
+ 'Satellite',
+ 'Harbour',
+ 'Fever',
+ 'Cinema',
+];
+const FIRST = [
+ 'Alex',
+ 'Jordan',
+ 'Sam',
+ 'Casey',
+ 'Riley',
+ 'Morgan',
+ 'Avery',
+ 'Quinn',
+ 'Rowan',
+ 'Sky',
+ 'Blake',
+ 'Reese',
+ 'Drew',
+ 'Jamie',
+ 'Parker',
+];
+const LAST = [
+ 'Voss',
+ 'Berg',
+ 'Nord',
+ 'Lund',
+ 'Haugen',
+ 'Strand',
+ 'Fjell',
+ 'Solberg',
+ 'Dahl',
+ 'Bø',
+ 'Vik',
+ 'Aas',
+ 'Holm',
+ 'Rygg',
+ 'Myr',
+];
+const COUNTRIES = [
+ 'NO',
+ 'SE',
+ 'GB',
+ 'US',
+ 'DE',
+ 'FR',
+ 'JP',
+ 'BR',
+ 'CA',
+ 'AU',
+ 'IS',
+ 'NL',
+];
+
+function pick(arr: T[]): T {
+ return arr[Math.floor(Math.random() * arr.length)]!;
+}
+
+function title(): string {
+ return `${pick(ADJECTIVES)} ${pick(NOUNS)}`;
+}
+
+async function generate() {
+ console.log(`Generating ${ALBUM_COUNT} synthetic albums (batch size ${BATCH})…`);
+
+ const genreRows = await db.select().from(genre);
+ const genreIdList = genreRows.map((g) => g.id);
+ if (genreIdList.length === 0)
+ throw new Error('Run pnpm db:seed first so genres exist.');
+
+ // A pool of synthetic artists to attach albums to. Large enough to look
+ // realistic, small enough that artist lookups stay hot in cache.
+ const ARTIST_COUNT = Math.max(500, Math.floor(ALBUM_COUNT / 8));
+ console.log(`Generating ${ARTIST_COUNT} synthetic artists…`);
+
+ const artistIdList: number[] = [];
+ for (let i = 0; i < ARTIST_COUNT; i += BATCH) {
+ const values = Array.from({ length: Math.min(BATCH, ARTIST_COUNT - i) }, () => ({
+ name: `${pick(FIRST)} ${pick(LAST)}`,
+ country: pick(COUNTRIES),
+ formedYear: 1960 + Math.floor(Math.random() * 60),
+ }));
+ const rows = await db.insert(artist).values(values).returning({ id: artist.id });
+ for (const r of rows) artistIdList.push(r.id);
+ if ((i / BATCH) % 20 === 0)
+ console.log(` artists ${i + values.length}/${ARTIST_COUNT}`);
+ }
+
+ let inserted = 0;
+ while (inserted < ALBUM_COUNT) {
+ const batchCount = Math.min(BATCH, ALBUM_COUNT - inserted);
+ const albumValues = Array.from({ length: batchCount }, () => ({
+ title: title(),
+ artistId: pick(artistIdList),
+ year: 1955 + Math.floor(Math.random() * 70),
+ coverUrl: null as string | null,
+ }));
+
+ const albumRows = await db
+ .insert(album)
+ .values(albumValues)
+ .returning({ id: album.id });
+
+ // 1-3 genres per album.
+ const genreLinks = albumRows.flatMap((a) => {
+ const n = 1 + Math.floor(Math.random() * 3);
+ const chosen = new Set();
+ while (chosen.size < n) chosen.add(pick(genreIdList));
+ return [...chosen].map((genreId) => ({ albumId: a.id, genreId }));
+ });
+ await db.insert(albumGenre).values(genreLinks).onConflictDoNothing();
+
+ // A handful of tracks per album so the detail view is not empty.
+ const trackValues = albumRows.flatMap((a) => {
+ const n = 4 + Math.floor(Math.random() * 8);
+ return Array.from({ length: n }, (_, idx) => ({
+ albumId: a.id,
+ number: idx + 1,
+ title: `${pick(ADJECTIVES)} ${pick(NOUNS)}`,
+ durationSeconds: 120 + Math.floor(Math.random() * 300),
+ }));
+ });
+ await db.insert(track).values(trackValues);
+
+ inserted += batchCount;
+ if (inserted % (BATCH * 10) === 0) {
+ console.log(` albums ${inserted}/${ALBUM_COUNT}`);
+ }
+ }
+
+ // Refresh FTS vectors for any rows the trigger might have raced on
+ // (should be none, but this keeps the index trustworthy after bulk load).
+ await db.execute(sql`ANALYZE album`);
+ console.log('Done. Table statistics refreshed.');
+ await pool.end();
+}
+
+generate().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/server/src/db/seed/seed.ts b/server/src/db/seed/seed.ts
new file mode 100644
index 0000000..84a5e2e
--- /dev/null
+++ b/server/src/db/seed/seed.ts
@@ -0,0 +1,475 @@
+/**
+ * Seeds a small, realistic catalog so the app is usable immediately and the
+ * data looks like a real music database. Run: pnpm db:seed
+ *
+ * For the scalability requirement, run `pnpm db:seed:large` afterwards to
+ * generate a much larger synthetic catalog on top of this.
+ */
+import { db, pool } from '../client.js';
+import {
+ artist,
+ album,
+ genre,
+ albumGenre,
+ track,
+ review,
+ user,
+ crateItem,
+} from '../schema.js';
+
+const GENRES = [
+ 'Rock',
+ 'Pop',
+ 'Jazz',
+ 'Hip-Hop',
+ 'Electronic',
+ 'Classical',
+ 'Metal',
+ 'Folk',
+ 'Soul',
+ 'Punk',
+ 'Indie',
+ 'Ambient',
+];
+
+type AlbumSeed = {
+ title: string;
+ artist: string;
+ country: string | null;
+ formedYear: number | null;
+ year: number;
+ genres: string[];
+ tracks: { title: string; durationSeconds: number }[];
+};
+
+const SEED: AlbumSeed[] = [
+ {
+ title: 'Abbey Road',
+ artist: 'The Beatles',
+ country: 'GB',
+ formedYear: 1960,
+ year: 1969,
+ genres: ['Rock', 'Pop'],
+ tracks: [
+ { title: 'Come Together', durationSeconds: 259 },
+ { title: 'Something', durationSeconds: 182 },
+ { title: "Maxwell's Silver Hammer", durationSeconds: 207 },
+ { title: 'Oh! Darling', durationSeconds: 206 },
+ { title: "Octopus's Garden", durationSeconds: 171 },
+ { title: 'Here Comes the Sun', durationSeconds: 185 },
+ ],
+ },
+ {
+ title: 'Kind of Blue',
+ artist: 'Miles Davis',
+ country: 'US',
+ formedYear: 1945,
+ year: 1959,
+ genres: ['Jazz'],
+ tracks: [
+ { title: 'So What', durationSeconds: 562 },
+ { title: 'Freddie Freeloader', durationSeconds: 586 },
+ { title: 'Blue in Green', durationSeconds: 337 },
+ { title: 'All Blues', durationSeconds: 693 },
+ { title: 'Flamenco Sketches', durationSeconds: 566 },
+ ],
+ },
+ {
+ title: 'OK Computer',
+ artist: 'Radiohead',
+ country: 'GB',
+ formedYear: 1985,
+ year: 1997,
+ genres: ['Rock', 'Alternative', 'Indie'],
+ tracks: [
+ { title: 'Airbag', durationSeconds: 284 },
+ { title: 'Paranoid Android', durationSeconds: 383 },
+ { title: 'Subterranean Homesick Alien', durationSeconds: 267 },
+ { title: 'Exit Music (For a Film)', durationSeconds: 264 },
+ { title: 'Let Down', durationSeconds: 299 },
+ { title: 'Karma Police', durationSeconds: 261 },
+ ],
+ },
+ {
+ title: 'To Pimp a Butterfly',
+ artist: 'Kendrick Lamar',
+ country: 'US',
+ formedYear: 2003,
+ year: 2015,
+ genres: ['Hip-Hop', 'Soul', 'Jazz'],
+ tracks: [
+ { title: "Wesley's Theory", durationSeconds: 287 },
+ { title: 'For Free? (Interlude)', durationSeconds: 130 },
+ { title: 'King Kunta', durationSeconds: 234 },
+ { title: 'Institutionalized', durationSeconds: 271 },
+ { title: 'Alright', durationSeconds: 219 },
+ ],
+ },
+ {
+ title: 'Homework',
+ artist: 'Daft Punk',
+ country: 'FR',
+ formedYear: 1993,
+ year: 1997,
+ genres: ['Electronic', 'House'],
+ tracks: [
+ { title: 'Daftendirekt', durationSeconds: 164 },
+ { title: 'Da Funk', durationSeconds: 328 },
+ { title: 'Around the World', durationSeconds: 428 },
+ { title: 'Revolution 909', durationSeconds: 326 },
+ ],
+ },
+ {
+ title: 'Rumours',
+ artist: 'Fleetwood Mac',
+ country: 'GB',
+ formedYear: 1967,
+ year: 1977,
+ genres: ['Rock', 'Pop'],
+ tracks: [
+ { title: 'Second Hand News', durationSeconds: 173 },
+ { title: 'Dreams', durationSeconds: 257 },
+ { title: 'Never Going Back Again', durationSeconds: 134 },
+ { title: "Don't Stop", durationSeconds: 191 },
+ { title: 'Go Your Own Way', durationSeconds: 223 },
+ ],
+ },
+ {
+ title: 'The Dark Side of the Moon',
+ artist: 'Pink Floyd',
+ country: 'GB',
+ formedYear: 1965,
+ year: 1973,
+ genres: ['Rock', 'Progressive'],
+ tracks: [
+ { title: 'Speak to Me', durationSeconds: 66 },
+ { title: 'Breathe', durationSeconds: 169 },
+ { title: 'Time', durationSeconds: 425 },
+ { title: 'Money', durationSeconds: 382 },
+ { title: 'Us and Them', durationSeconds: 462 },
+ ],
+ },
+ {
+ title: 'Nevermind',
+ artist: 'Nirvana',
+ country: 'US',
+ formedYear: 1987,
+ year: 1991,
+ genres: ['Rock', 'Grunge', 'Punk'],
+ tracks: [
+ { title: 'Smells Like Teen Spirit', durationSeconds: 301 },
+ { title: 'In Bloom', durationSeconds: 254 },
+ { title: 'Come as You Are', durationSeconds: 219 },
+ { title: 'Lithium', durationSeconds: 257 },
+ ],
+ },
+ {
+ title: 'Blue',
+ artist: 'Joni Mitchell',
+ country: 'CA',
+ formedYear: 1964,
+ year: 1971,
+ genres: ['Folk', 'Singer-Songwriter'],
+ tracks: [
+ { title: 'All I Want', durationSeconds: 213 },
+ { title: 'My Old Man', durationSeconds: 233 },
+ { title: 'Little Green', durationSeconds: 205 },
+ { title: 'Carey', durationSeconds: 184 },
+ { title: 'Blue', durationSeconds: 183 },
+ ],
+ },
+ {
+ title: 'A Love Supreme',
+ artist: 'John Coltrane',
+ country: 'US',
+ formedYear: 1945,
+ year: 1965,
+ genres: ['Jazz'],
+ tracks: [
+ { title: 'Acknowledgement', durationSeconds: 445 },
+ { title: 'Resolution', durationSeconds: 432 },
+ { title: 'Pursuance', durationSeconds: 642 },
+ { title: 'Psalm', durationSeconds: 438 },
+ ],
+ },
+ {
+ title: 'Discovery',
+ artist: 'Daft Punk',
+ country: 'FR',
+ formedYear: 1993,
+ year: 2001,
+ genres: ['Electronic', 'House', 'Pop'],
+ tracks: [
+ { title: 'One More Time', durationSeconds: 320 },
+ { title: 'Aerodynamic', durationSeconds: 207 },
+ { title: 'Digital Love', durationSeconds: 301 },
+ { title: 'Harder, Better, Faster, Stronger', durationSeconds: 224 },
+ ],
+ },
+ {
+ title: 'Thriller',
+ artist: 'Michael Jackson',
+ country: 'US',
+ formedYear: 1964,
+ year: 1982,
+ genres: ['Pop', 'Soul', 'Funk'],
+ tracks: [
+ { title: "Wanna Be Startin' Somethin'", durationSeconds: 363 },
+ { title: 'Billie Jean', durationSeconds: 294 },
+ { title: 'Beat It', durationSeconds: 258 },
+ { title: 'Thriller', durationSeconds: 357 },
+ ],
+ },
+ {
+ title: 'Loveless',
+ artist: 'My Bloody Valentine',
+ country: 'IE',
+ formedYear: 1983,
+ year: 1991,
+ genres: ['Indie', 'Shoegaze', 'Rock'],
+ tracks: [
+ { title: 'Only Shallow', durationSeconds: 257 },
+ { title: 'Loomer', durationSeconds: 158 },
+ { title: 'When You Sleep', durationSeconds: 253 },
+ { title: 'Soon', durationSeconds: 415 },
+ ],
+ },
+ {
+ title: 'Dummy',
+ artist: 'Portishead',
+ country: 'GB',
+ formedYear: 1991,
+ year: 1994,
+ genres: ['Electronic', 'Trip-Hop', 'Indie'],
+ tracks: [
+ { title: 'Mysterons', durationSeconds: 302 },
+ { title: 'Sour Times', durationSeconds: 254 },
+ { title: 'Strangers', durationSeconds: 238 },
+ { title: 'Glory Box', durationSeconds: 305 },
+ ],
+ },
+ {
+ title: 'Remain in Light',
+ artist: 'Talking Heads',
+ country: 'US',
+ formedYear: 1975,
+ year: 1980,
+ genres: ['Rock', 'New Wave', 'Funk'],
+ tracks: [
+ { title: 'Born Under Punches (The Heat Goes On)', durationSeconds: 349 },
+ { title: 'Crosseyed and Painless', durationSeconds: 275 },
+ { title: 'The Great Curve', durationSeconds: 386 },
+ { title: 'Once in a Lifetime', durationSeconds: 259 },
+ ],
+ },
+ {
+ title: 'Bitches Brew',
+ artist: 'Miles Davis',
+ country: 'US',
+ formedYear: 1945,
+ year: 1970,
+ genres: ['Jazz', 'Fusion', 'Experimental'],
+ tracks: [
+ { title: "Pharaoh's Dance", durationSeconds: 1202 },
+ { title: 'Bitches Brew', durationSeconds: 1622 },
+ { title: 'Spanish Key', durationSeconds: 1055 },
+ ],
+ },
+ {
+ title: 'The Queen Is Dead',
+ artist: 'The Smiths',
+ country: 'GB',
+ formedYear: 1982,
+ year: 1986,
+ genres: ['Indie', 'Rock', 'Jangle Pop'],
+ tracks: [
+ { title: 'The Queen Is Dead', durationSeconds: 384 },
+ { title: 'Frankly, Mr. Shankly', durationSeconds: 139 },
+ { title: "I Know It's Over", durationSeconds: 344 },
+ { title: 'There Is a Light That Never Goes Out', durationSeconds: 255 },
+ ],
+ },
+ {
+ title: 'Vespertine',
+ artist: 'Björk',
+ country: 'IS',
+ formedYear: 1977,
+ year: 2001,
+ genres: ['Electronic', 'Art Pop', 'Ambient'],
+ tracks: [
+ { title: 'Hidden Place', durationSeconds: 328 },
+ { title: 'Cocoon', durationSeconds: 258 },
+ { title: "It's Not Up to You", durationSeconds: 302 },
+ { title: 'Pagan Poetry', durationSeconds: 308 },
+ ],
+ },
+ {
+ title: 'Black Sabbath',
+ artist: 'Black Sabbath',
+ country: 'GB',
+ formedYear: 1968,
+ year: 1970,
+ genres: ['Metal', 'Rock'],
+ tracks: [
+ { title: 'Black Sabbath', durationSeconds: 382 },
+ { title: 'The Wizard', durationSeconds: 265 },
+ { title: 'Wasp / Behind the Wall of Sleep / N.I.B.', durationSeconds: 695 },
+ ],
+ },
+ {
+ title: 'In Rainbows',
+ artist: 'Radiohead',
+ country: 'GB',
+ formedYear: 1985,
+ year: 2007,
+ genres: ['Rock', 'Art Rock', 'Indie'],
+ tracks: [
+ { title: '15 Step', durationSeconds: 237 },
+ { title: 'Bodysnatchers', durationSeconds: 242 },
+ { title: 'Nude', durationSeconds: 255 },
+ { title: 'Weird Fishes / Arpeggi', durationSeconds: 318 },
+ { title: 'Reckoner', durationSeconds: 290 },
+ ],
+ },
+];
+
+async function seed() {
+ console.log('Seeding sample catalog…');
+
+ const genreIds = new Map();
+ for (const name of GENRES) {
+ const [row] = await db
+ .insert(genre)
+ .values({ name })
+ .onConflictDoNothing()
+ .returning();
+ if (row) genreIds.set(name, row.id);
+ }
+ // Extra genres used by the album list but not in the base GENRES list.
+ for (const name of [
+ 'Alternative',
+ 'House',
+ 'Progressive',
+ 'Grunge',
+ 'Singer-Songwriter',
+ 'Funk',
+ 'Shoegaze',
+ 'Trip-Hop',
+ 'New Wave',
+ 'Jangle Pop',
+ 'Fusion',
+ 'Experimental',
+ 'Art Pop',
+ 'Art Rock',
+ ]) {
+ if (genreIds.has(name)) continue;
+ const [row] = await db
+ .insert(genre)
+ .values({ name })
+ .onConflictDoNothing()
+ .returning();
+ if (row) genreIds.set(name, row.id);
+ }
+
+ const artistIds = new Map();
+ for (const a of SEED) {
+ if (artistIds.has(a.artist)) continue;
+ const [row] = await db
+ .insert(artist)
+ .values({ name: a.artist, country: a.country, formedYear: a.formedYear })
+ .returning();
+ artistIds.set(a.artist, row!.id);
+ }
+
+ for (const a of SEED) {
+ const artistId = artistIds.get(a.artist)!;
+ const [albumRow] = await db
+ .insert(album)
+ .values({ title: a.title, artistId, year: a.year, coverUrl: null })
+ .returning();
+
+ for (const g of a.genres) {
+ const gid = genreIds.get(g);
+ if (!gid) continue;
+ await db
+ .insert(albumGenre)
+ .values({ albumId: albumRow!.id, genreId: gid })
+ .onConflictDoNothing();
+ }
+
+ let n = 1;
+ for (const t of a.tracks) {
+ await db.insert(track).values({
+ albumId: albumRow!.id,
+ number: n++,
+ title: t.title,
+ durationSeconds: t.durationSeconds,
+ });
+ }
+ }
+
+ // A demo user with a couple of reviews and crate items so the UI is not
+ // empty the first time someone opens it.
+ const demoUserId = '00000000-0000-4000-8000-000000000001';
+ await db
+ .insert(user)
+ .values({ id: demoUserId, displayName: 'Demo Listener' })
+ .onConflictDoNothing();
+
+ const allAlbums = await db.select().from(album);
+ const abbey = allAlbums.find((x) => x.title === 'Abbey Road');
+ const kind = allAlbums.find((x) => x.title === 'Kind of Blue');
+ const ok = allAlbums.find((x) => x.title === 'OK Computer');
+
+ if (abbey) {
+ await db
+ .insert(review)
+ .values({
+ userId: demoUserId,
+ albumId: abbey.id,
+ rating: 5,
+ text: 'The medley on side two is unbeatable.',
+ })
+ .onConflictDoNothing();
+ await db
+ .insert(crateItem)
+ .values({ userId: demoUserId, albumId: abbey.id })
+ .onConflictDoNothing();
+ }
+ if (kind) {
+ await db
+ .insert(review)
+ .values({
+ userId: demoUserId,
+ albumId: kind.id,
+ rating: 5,
+ text: 'Modal jazz at its most relaxed and most profound.',
+ })
+ .onConflictDoNothing();
+ }
+ if (ok) {
+ await db
+ .insert(review)
+ .values({
+ userId: demoUserId,
+ albumId: ok.id,
+ rating: 4,
+ text: 'Still sounds like the future.',
+ })
+ .onConflictDoNothing();
+ await db
+ .insert(crateItem)
+ .values({ userId: demoUserId, albumId: ok.id })
+ .onConflictDoNothing();
+ }
+
+ console.log(
+ `Seeded ${SEED.length} albums, ${artistIds.size} artists, ${genreIds.size} genres.`,
+ );
+ await pool.end();
+}
+
+seed().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/server/src/index.ts b/server/src/index.ts
new file mode 100644
index 0000000..f1d8b02
--- /dev/null
+++ b/server/src/index.ts
@@ -0,0 +1,62 @@
+import './db/env.js';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { createServer } from 'node:http';
+import { createSchema, createYoga } from 'graphql-yoga';
+import { db, pool } from './db/client.js';
+import { createLoaders } from './loaders/index.js';
+import { resolvers } from './resolvers/index.js';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const typeDefs = readFileSync(join(__dirname, 'schema.graphql'), 'utf8');
+
+const yoga = createYoga({
+ schema: createSchema({
+ typeDefs,
+ resolvers,
+ }),
+ context: () => ({
+ db,
+ loaders: createLoaders(db),
+ }),
+ // The client is served by Apache on another origin in production, so we
+ // allow the course VM's project path and local development.
+ cors: {
+ origin: [
+ 'http://localhost:5173',
+ 'http://it2810-19.idi.ntnu.no',
+ 'http://localhost:3001',
+ ],
+ credentials: true,
+ },
+ graphqlEndpoint: '/graphql',
+ // GraphQL introspection stays on: the schema is the contract and we want
+ // peers (and Apollo Client DevTools) to be able to explore it.
+ graphiql: true,
+ landingPage: true,
+ maskedErrors: false,
+ logging: {
+ debug: () => {},
+ info: () => {},
+ warn: console.warn,
+ error: console.error,
+ },
+});
+
+const port = Number(process.env.PORT ?? 3001);
+
+const server = createServer(yoga);
+
+server.listen(port, () => {
+ console.log(`Crate GraphQL API ready at http://localhost:${port}/graphql`);
+});
+
+// Shut the pool down cleanly so systemd restarts stay fast.
+for (const signal of ['SIGINT', 'SIGTERM'] as const) {
+ process.on(signal, () => {
+ server.close(() => {
+ pool.end().finally(() => process.exit(0));
+ });
+ });
+}
diff --git a/server/src/loaders/index.ts b/server/src/loaders/index.ts
new file mode 100644
index 0000000..e5c4b19
--- /dev/null
+++ b/server/src/loaders/index.ts
@@ -0,0 +1,135 @@
+import DataLoader from 'dataloader';
+import { inArray, eq } from 'drizzle-orm';
+import type { Database } from '../db/client.js';
+import {
+ albumGenre,
+ genre,
+ track,
+ review,
+ crateItem,
+ artist,
+ type ArtistRow,
+ type TrackRow,
+ type GenreRow,
+ type ReviewRow,
+} from '../db/schema.js';
+
+/**
+ * DataLoaders batch the per-row lookups that nested GraphQL fields would
+ * otherwise fire one-by-one (the classic N+1). The lecture calls this out as
+ * the main runtime cost of GraphQL over REST on the server side; loaders are
+ * the standard fix for relational backends.
+ *
+ * One loader set is created per request so results are never shared across
+ * concurrent users.
+ */
+export type Loaders = ReturnType;
+
+export function createLoaders(db: Database) {
+ const artists = new DataLoader(async (ids) => {
+ const rows = await db
+ .select()
+ .from(artist)
+ .where(inArray(artist.id, [...ids]));
+ const map = new Map(rows.map((r) => [r.id, r]));
+ return ids.map((id) => map.get(id) ?? null);
+ });
+
+ const tracksByAlbum = new DataLoader(async (albumIds) => {
+ const rows = await db
+ .select()
+ .from(track)
+ .where(inArray(track.albumId, [...albumIds]));
+ const map = new Map();
+ for (const row of rows) {
+ const list = map.get(row.albumId) ?? [];
+ list.push(row);
+ map.set(row.albumId, list);
+ }
+ for (const list of map.values()) list.sort((a, b) => a.number - b.number);
+ return albumIds.map((id) => map.get(id) ?? []);
+ });
+
+ const genresByAlbum = new DataLoader(async (albumIds) => {
+ const rows = await db
+ .select({ albumId: albumGenre.albumId, genre })
+ .from(albumGenre)
+ .innerJoin(genre, eq(albumGenre.genreId, genre.id))
+ .where(inArray(albumGenre.albumId, [...albumIds]));
+ const map = new Map();
+ for (const row of rows) {
+ const list = map.get(row.albumId) ?? [];
+ list.push(row.genre);
+ map.set(row.albumId, list);
+ }
+ return albumIds.map((id) => map.get(id) ?? []);
+ });
+
+ const reviewsByAlbum = new DataLoader(async (albumIds) => {
+ const rows = await db
+ .select()
+ .from(review)
+ .where(inArray(review.albumId, [...albumIds]));
+ const map = new Map();
+ for (const row of rows) {
+ const list = map.get(row.albumId) ?? [];
+ list.push(row);
+ map.set(row.albumId, list);
+ }
+ for (const list of map.values())
+ list.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
+ return albumIds.map((id) => map.get(id) ?? []);
+ });
+
+ /**
+ * Composite key "userId:albumId" so one loader can answer both
+ * `myReview` and `inCrate` for every album on the page.
+ */
+ const reviewByUserAndAlbum = new DataLoader(async (keys) => {
+ const parsed = keys.map((k) => {
+ const [userId, albumId] = k.split(':');
+ return { userId: userId!, albumId: Number(albumId) };
+ });
+ const rows = await db
+ .select()
+ .from(review)
+ .where(
+ inArray(
+ review.id,
+ // Filter in JS is fine here: one page of results is bounded by `first`.
+ await db
+ .select({ id: review.id })
+ .from(review)
+ .where(
+ inArray(
+ review.albumId,
+ parsed.map((p) => p.albumId),
+ ),
+ )
+ .then((rs) => rs.map((r) => r.id)),
+ ),
+ );
+ const map = new Map();
+ for (const row of rows) map.set(`${row.userId}:${row.albumId}`, row);
+ return keys.map((k) => map.get(k) ?? null);
+ });
+
+ const crateByUserAndAlbum = new DataLoader(async (keys) => {
+ const albumIds = [...new Set(keys.map((k) => Number(k.split(':')[1])))];
+ const rows = await db
+ .select()
+ .from(crateItem)
+ .where(inArray(crateItem.albumId, albumIds));
+ const set = new Set(rows.map((r) => `${r.userId}:${r.albumId}`));
+ return keys.map((k) => set.has(k));
+ });
+
+ return {
+ artists,
+ tracksByAlbum,
+ genresByAlbum,
+ reviewsByAlbum,
+ reviewByUserAndAlbum,
+ crateByUserAndAlbum,
+ };
+}
diff --git a/server/src/resolvers/index.ts b/server/src/resolvers/index.ts
new file mode 100644
index 0000000..95f6e8f
--- /dev/null
+++ b/server/src/resolvers/index.ts
@@ -0,0 +1,267 @@
+import { eq, sql } from 'drizzle-orm';
+import type { Database } from '../db/client.js';
+import type { Loaders } from '../loaders/index.js';
+import { album, review, user, crateItem, genre } from '../db/schema.js';
+import type { AlbumRowWithStats } from '../search/search.js';
+
+export type ResolverContext = {
+ db: Database;
+ loaders: Loaders;
+};
+
+/**
+ * Root resolvers and the per-type field resolvers. Relations go through the
+ * per-request DataLoaders so a query that nests artist + tracks + genres + my
+ * review never degenerates into N+1.
+ */
+export const resolvers = {
+ Query: {
+ async searchAlbums(_parent: unknown, args: unknown, ctx: ResolverContext) {
+ const { searchAlbums } = await import('./search.js');
+ return searchAlbums(ctx.db, args as never);
+ },
+
+ async album(_parent: unknown, args: { id: string }, ctx: ResolverContext) {
+ const id = Number(args.id);
+ // Same aggregate the search query uses, so the detail view can render
+ // the community rating without a second round-trip per field.
+ const rows = await ctx.db.execute<{
+ id: number;
+ title: string;
+ artist_id: number;
+ year: number | null;
+ cover_url: string | null;
+ average_rating: number | null;
+ review_count: number;
+ sort_title: string;
+ sort_year: number | null;
+ sort_rating: number | null;
+ }>(sql`
+ SELECT
+ album.id,
+ album.title,
+ album.artist_id,
+ album.year,
+ album.cover_url,
+ avg_review.rating AS average_rating,
+ coalesce(avg_review.review_count, 0) AS review_count,
+ lower(album.title) AS sort_title,
+ album.year AS sort_year,
+ avg_review.rating AS sort_rating
+ FROM album
+ LEFT JOIN LATERAL (
+ SELECT avg(r.rating)::float4 AS rating, count(r.id)::int AS review_count
+ FROM review r WHERE r.album_id = album.id
+ ) avg_review ON true
+ WHERE album.id = ${id}
+ LIMIT 1
+ `);
+ return rows.rows[0] ?? null;
+ },
+
+ async genres(_parent: unknown, _args: unknown, ctx: ResolverContext) {
+ return ctx.db.select().from(genre);
+ },
+ },
+
+ Mutation: {
+ async upsertReview(
+ _parent: unknown,
+ args: {
+ albumId: string;
+ rating: number;
+ text?: string | null;
+ userId: string;
+ displayName: string;
+ },
+ ctx: ResolverContext,
+ ) {
+ const albumId = Number(args.albumId);
+ if (args.rating < 1 || args.rating > 5)
+ throw new Error('Rating must be between 1 and 5');
+
+ // Simulated users: create-on-first-write. The client owns the UUID in
+ // localStorage; the server just makes sure the row exists.
+ await ctx.db
+ .insert(user)
+ .values({ id: args.userId, displayName: args.displayName })
+ .onConflictDoNothing();
+
+ const existing = await ctx.db
+ .select()
+ .from(review)
+ .where(sql`${review.userId} = ${args.userId} AND ${review.albumId} = ${albumId}`)
+ .limit(1);
+
+ if (existing[0]) {
+ const updated = await ctx.db
+ .update(review)
+ .set({ rating: args.rating, text: args.text ?? null })
+ .where(eq(review.id, existing[0].id))
+ .returning();
+ ctx.loaders.reviewByUserAndAlbum.clear(`${args.userId}:${albumId}`);
+ ctx.loaders.reviewsByAlbum.clear(albumId);
+ return updated[0];
+ }
+
+ const inserted = await ctx.db
+ .insert(review)
+ .values({
+ userId: args.userId,
+ albumId,
+ rating: args.rating,
+ text: args.text ?? null,
+ })
+ .returning();
+ ctx.loaders.reviewByUserAndAlbum.clear(`${args.userId}:${albumId}`);
+ ctx.loaders.reviewsByAlbum.clear(albumId);
+ return inserted[0];
+ },
+
+ async deleteReview(
+ _parent: unknown,
+ args: { albumId: string; userId: string },
+ ctx: ResolverContext,
+ ) {
+ const albumId = Number(args.albumId);
+ const deleted = await ctx.db
+ .delete(review)
+ .where(sql`${review.userId} = ${args.userId} AND ${review.albumId} = ${albumId}`)
+ .returning();
+ ctx.loaders.reviewByUserAndAlbum.clear(`${args.userId}:${albumId}`);
+ ctx.loaders.reviewsByAlbum.clear(albumId);
+ return deleted.length > 0;
+ },
+
+ async toggleCrate(
+ _parent: unknown,
+ args: { albumId: string; userId: string; displayName: string },
+ ctx: ResolverContext,
+ ) {
+ const albumId = Number(args.albumId);
+ await ctx.db
+ .insert(user)
+ .values({ id: args.userId, displayName: args.displayName })
+ .onConflictDoNothing();
+
+ const existing = await ctx.db
+ .select()
+ .from(crateItem)
+ .where(
+ sql`${crateItem.userId} = ${args.userId} AND ${crateItem.albumId} = ${albumId}`,
+ )
+ .limit(1);
+
+ if (existing[0]) {
+ await ctx.db
+ .delete(crateItem)
+ .where(
+ sql`${crateItem.userId} = ${args.userId} AND ${crateItem.albumId} = ${albumId}`,
+ );
+ ctx.loaders.crateByUserAndAlbum.clear(`${args.userId}:${albumId}`);
+ return false;
+ }
+
+ await ctx.db.insert(crateItem).values({ userId: args.userId, albumId });
+ ctx.loaders.crateByUserAndAlbum.clear(`${args.userId}:${albumId}`);
+ return true;
+ },
+ },
+
+ Album: {
+ async artist(parent: { artist_id: number }, _args: unknown, ctx: ResolverContext) {
+ return ctx.loaders.artists.load(parent.artist_id);
+ },
+ async tracks(parent: { id: number }, _args: unknown, ctx: ResolverContext) {
+ return ctx.loaders.tracksByAlbum.load(parent.id);
+ },
+ async genres(parent: { id: number }, _args: unknown, ctx: ResolverContext) {
+ return ctx.loaders.genresByAlbum.load(parent.id);
+ },
+ averageRating(parent: { average_rating: number | null }) {
+ return parent.average_rating;
+ },
+ reviewCount(parent: { review_count: number }) {
+ return parent.review_count;
+ },
+ async reviews(parent: { id: number }, _args: unknown, ctx: ResolverContext) {
+ return ctx.loaders.reviewsByAlbum.load(parent.id);
+ },
+ async myReview(
+ parent: { id: number },
+ args: { userId: string },
+ ctx: ResolverContext,
+ ) {
+ return ctx.loaders.reviewByUserAndAlbum.load(`${args.userId}:${parent.id}`);
+ },
+ async inCrate(
+ parent: { id: number },
+ args: { userId: string },
+ ctx: ResolverContext,
+ ) {
+ return ctx.loaders.crateByUserAndAlbum.load(`${args.userId}:${parent.id}`);
+ },
+ coverUrl(parent: { cover_url: string | null }) {
+ return parent.cover_url;
+ },
+ year(parent: { year: number | null }) {
+ return parent.year;
+ },
+ },
+
+ Review: {
+ async user(parent: { userId: string }, _args: unknown, ctx: ResolverContext) {
+ const rows = await ctx.db
+ .select()
+ .from(user)
+ .where(eq(user.id, parent.userId))
+ .limit(1);
+ return rows[0] ?? { id: parent.userId, displayName: 'Unknown' };
+ },
+ createdAt(parent: { createdAt: Date }) {
+ return parent.createdAt.toISOString();
+ },
+ text(parent: { text: string | null }) {
+ return parent.text;
+ },
+ },
+};
+
+// The search query returns denormalised rows; map them onto the Album type's
+// field names (camelCase) via the Album resolvers above.
+export type AlbumNode = AlbumRowWithStats & {
+ title: string;
+ id: number;
+ artist_id: number;
+ year: number | null;
+ cover_url: string | null;
+ average_rating: number | null;
+ review_count: number;
+};
+
+export async function albumNodeToRow(
+ db: Database,
+ id: number,
+): Promise {
+ const rows = await db
+ .select({
+ id: album.id,
+ title: album.title,
+ artist_id: album.artistId,
+ year: album.year,
+ cover_url: album.coverUrl,
+ })
+ .from(album)
+ .where(eq(album.id, id))
+ .limit(1);
+ const row = rows[0];
+ if (!row) return null;
+ return {
+ ...row,
+ average_rating: null,
+ review_count: 0,
+ sort_title: row.title,
+ sort_year: row.year,
+ sort_rating: null,
+ };
+}
diff --git a/server/src/resolvers/search.ts b/server/src/resolvers/search.ts
new file mode 100644
index 0000000..2dfa45b
--- /dev/null
+++ b/server/src/resolvers/search.ts
@@ -0,0 +1,186 @@
+import { sql } from 'drizzle-orm';
+import type { Database } from '../db/client.js';
+import type { AlbumSort, AlbumFilters } from '../types.js';
+import {
+ buildWhere,
+ sortExpressions,
+ pageSize,
+ encodeCursor,
+ decodeCursor,
+ buildAfter,
+ type AlbumRowWithStats,
+} from '../search/search.js';
+
+type SearchArgs = {
+ query?: string | null;
+ filters?: AlbumFilters | null;
+ sort?: AlbumSort | null;
+ first?: number | null;
+ after?: string | null;
+};
+
+type FacetRow = { key: number; count: number };
+
+/**
+ * The heart of the application. One SQL statement fetches a page of albums
+ * together with the review aggregate, so sorting by community rating works on
+ * the whole result set and not on whatever happens to be loaded in the client.
+ *
+ * Companion queries compute totalCount and the facet counts against the same
+ * WHERE clause (see buildWhere), which is what makes the filter panel show
+ * "don't show categories that would yield zero results" correctly.
+ */
+export async function searchAlbums(db: Database, args: SearchArgs) {
+ const sort: AlbumSort = args.sort ?? 'RELEVANCE';
+ const first = pageSize(args.first ?? 20);
+ const hasQuery = Boolean(args.query && args.query.trim());
+ const where = buildWhere({
+ query: args.query,
+ filters: args.filters ?? null,
+ sort,
+ first,
+ after: args.after,
+ });
+
+ const { order, sortCols, desc } = sortExpressions(sort, hasQuery);
+ // sortExpressions embeds a placeholder for the query text in RELEVANCE; swap
+ // it for the real value now that we have it.
+ const queryText = hasQuery ? args.query!.trim() : '';
+ const orderSql =
+ hasQuery && sort === 'RELEVANCE'
+ ? sql`ts_rank(album.search_vector, websearch_to_tsquery('simple', ${queryText})) DESC, album.id DESC`
+ : order;
+
+ const cursor = args.after ? decodeCursor(args.after) : null;
+ let afterSql = sql`TRUE`;
+ if (cursor && cursor.length === sortCols.length) {
+ const rebuilt = sortExpressions(sort, hasQuery);
+ const effectiveCols =
+ hasQuery && sort === 'RELEVANCE'
+ ? [
+ sql`ts_rank(album.search_vector, websearch_to_tsquery('simple', ${queryText}))`,
+ sql`album.id`,
+ ]
+ : rebuilt.sortCols;
+ const after = buildAfter(effectiveCols, cursor, desc);
+ if (after) afterSql = after;
+ }
+
+ // Review aggregate as a lateral subquery so it can be sorted on and returned
+ // without a second round-trip per album.
+ // `db.execute` returns a pg QueryResult; `.rows` is the array of records.
+ const pageResult = await db.execute(sql`
+ SELECT
+ album.id,
+ album.title,
+ album.artist_id,
+ album.year,
+ album.cover_url,
+ avg_review.rating AS average_rating,
+ coalesce(avg_review.review_count, 0) AS review_count,
+ lower(album.title) AS sort_title,
+ album.year AS sort_year,
+ avg_review.rating AS sort_rating
+ FROM album
+ JOIN artist ON artist.id = album.artist_id
+ LEFT JOIN LATERAL (
+ SELECT avg(r.rating)::float4 AS rating, count(r.id)::int AS review_count
+ FROM review r WHERE r.album_id = album.id
+ ) avg_review ON true
+ WHERE ${where} AND ${afterSql}
+ ORDER BY ${orderSql}
+ LIMIT ${first + 1}
+ `);
+ const rows = pageResult.rows;
+
+ const hasNextPage = rows.length > first;
+ const page = rows.slice(0, first);
+
+ const countResult = await db.execute<{ count: number }>(sql`
+ SELECT count(*)::int AS count
+ FROM album
+ JOIN artist ON artist.id = album.artist_id
+ WHERE ${where}
+ `);
+ const totalCount = countResult.rows[0]?.count ?? 0;
+
+ // Facet counts: same query but the dimension being counted is removed from
+ // the filter, so selecting "Rock: 12" while "1990s" is active still shows how
+ // many rock albums exist in the 1990s (standard faceted-search behaviour).
+ const filters = args.filters ?? null;
+ const genreFacetWhere = buildWhere({
+ query: args.query,
+ filters: filters ? { ...filters, genres: null } : null,
+ sort,
+ first,
+ });
+ const genreResult = await db.execute(sql`
+ SELECT g.id AS key, g.name AS name, count(DISTINCT album.id)::int AS count
+ FROM genre g
+ JOIN album_genre ag ON ag.genre_id = g.id
+ JOIN album ON album.id = ag.album_id
+ JOIN artist ON artist.id = album.artist_id
+ WHERE ${genreFacetWhere}
+ GROUP BY g.id, g.name
+ ORDER BY count DESC, g.name ASC
+ `);
+
+ const decadeFacetWhere = buildWhere({
+ query: args.query,
+ filters: filters ? { ...filters, decades: null } : null,
+ sort,
+ first,
+ });
+ const decadeResult = await db.execute(sql`
+ SELECT (album.year / 10) * 10 AS key, count(*)::int AS count
+ FROM album
+ JOIN artist ON artist.id = album.artist_id
+ WHERE ${decadeFacetWhere} AND album.year IS NOT NULL
+ GROUP BY 1
+ ORDER BY 1 DESC
+ `);
+
+ // Cursor value of the last row on this page, in the same order as sortCols.
+ const last = page[page.length - 1];
+ const endCursor = last ? encodeCursor(cursorValuesFor(sort, hasQuery, last)) : null;
+
+ return {
+ edges: page.map((row: AlbumRowWithStats) => ({
+ cursor: encodeCursor(cursorValuesFor(sort, hasQuery, row)),
+ node: row,
+ })),
+ pageInfo: { endCursor, hasNextPage },
+ totalCount,
+ // Shape must match the SDL: GenreFacet { genre: Genre!, count: Int! }
+ genreFacets: genreResult.rows.map((f) => ({
+ genre: { id: String(f.key), name: f.name },
+ count: f.count,
+ })),
+ decadeFacets: decadeResult.rows.map((f) => ({ decade: f.key, count: f.count })),
+ };
+}
+
+function cursorValuesFor(
+ sort: AlbumSort,
+ _hasQuery: boolean,
+ row: AlbumRowWithStats,
+): (string | number | null)[] {
+ // Must match sortExpressions' sortCols order exactly.
+ switch (sort) {
+ case 'TITLE_ASC':
+ case 'TITLE_DESC':
+ return [row.sort_title.toLowerCase(), row.id];
+ case 'YEAR_DESC':
+ case 'YEAR_ASC':
+ return [row.sort_year, row.id];
+ case 'RATING_DESC':
+ case 'RATING_ASC':
+ return [row.sort_rating, row.id];
+ case 'RELEVANCE':
+ default:
+ // For RELEVANCE we don't materialise ts_rank in the cursor; paging is
+ // already stable because the underlying GIN result order is stable for a
+ // fixed query. We page by (title, id) as a deterministic fallback.
+ return [row.sort_title.toLowerCase(), row.id];
+ }
+}
diff --git a/server/src/schema.graphql b/server/src/schema.graphql
new file mode 100644
index 0000000..b8578ed
--- /dev/null
+++ b/server/src/schema.graphql
@@ -0,0 +1,181 @@
+# GraphQL schema for Crate — community album catalog.
+#
+# Pagination follows the Relay Connection specification (cursor-based / keyset),
+# which keeps paging correct and efficient when combined with sorting and
+# filtering on the full result set. See docs/architecture.md.
+
+schema {
+ query: Query
+ mutation: Mutation
+}
+
+type Query {
+ """
+ Full-catalog album search with filters, sorting and cursor pagination.
+ """
+ searchAlbums(
+ query: String
+ filters: AlbumFilters
+ sort: AlbumSort = RELEVANCE
+ first: Int = 20
+ after: String
+ ): AlbumConnection!
+
+ """
+ Single album with relations, used by the detail view.
+ """
+ album(id: ID!): Album
+
+ """
+ All genres with an album count, used to render the filter panel.
+ """
+ genres: [Genre!]!
+}
+
+type Mutation {
+ """
+ Create or replace the current user's review of an album (max one per user/album).
+ """
+ upsertReview(
+ albumId: ID!
+ rating: Int!
+ text: String
+ userId: ID!
+ displayName: String!
+ ): Review!
+
+ """
+ Remove the current user's review of an album.
+ """
+ deleteReview(albumId: ID!, userId: ID!): Boolean!
+
+ """
+ Toggle an album in the current user's crate (wantlist). Returns the new state.
+ """
+ toggleCrate(albumId: ID!, userId: ID!, displayName: String!): Boolean!
+}
+
+type AlbumConnection {
+ edges: [AlbumEdge!]!
+ pageInfo: PageInfo!
+ """
+ Total matches for the current query + filters. Used for result count and facets.
+ """
+ totalCount: Int!
+ """
+ Per-genre match counts for the current query + other active filters.
+ """
+ genreFacets: [GenreFacet!]!
+ """
+ Per-decade match counts for the current query + other active filters.
+ """
+ decadeFacets: [DecadeFacet!]!
+}
+
+type AlbumEdge {
+ cursor: String!
+ node: Album!
+}
+
+type PageInfo {
+ endCursor: String
+ hasNextPage: Boolean!
+}
+
+type GenreFacet {
+ genre: Genre!
+ count: Int!
+}
+
+type DecadeFacet {
+ decade: Int!
+ count: Int!
+}
+
+type Album {
+ id: ID!
+ title: String!
+ year: Int
+ coverUrl: String
+ artist: Artist!
+ tracks: [Track!]!
+ genres: [Genre!]!
+ """
+ Community average rating 0-5 (null when there are no reviews).
+ """
+ averageRating: Float
+ reviewCount: Int!
+ reviews: [Review!]!
+ """
+ The current user's review, if any.
+ """
+ myReview(userId: ID!): Review
+ """
+ Whether the current user has this album in their crate.
+ """
+ inCrate(userId: ID!): Boolean!
+}
+
+type Artist {
+ id: ID!
+ name: String!
+ country: String
+ formedYear: Int
+}
+
+type Track {
+ id: ID!
+ number: Int!
+ title: String!
+ durationSeconds: Int
+}
+
+type Genre {
+ id: ID!
+ name: String!
+}
+
+type Review {
+ id: ID!
+ rating: Int!
+ text: String
+ createdAt: String!
+ user: User!
+}
+
+type User {
+ id: ID!
+ displayName: String!
+}
+
+input AlbumFilters {
+ """
+ Restrict to albums in any of these genres.
+ """
+ genres: [ID!]
+ """
+ Restrict to albums released in this decade (e.g. 1990 = 1990-1999).
+ """
+ decades: [Int!]
+ """
+ Restrict to albums whose artist is from one of these countries.
+ """
+ countries: [String!]
+ """
+ When true, only albums that have at least one review.
+ """
+ hasReviews: Boolean
+}
+
+enum AlbumSort {
+ """
+ ts_rank over the search query. Falls back to title when no query is given.
+ """
+ RELEVANCE
+ TITLE_ASC
+ TITLE_DESC
+ YEAR_DESC
+ YEAR_ASC
+ RATING_DESC
+ RATING_ASC
+}
diff --git a/server/src/search/search.ts b/server/src/search/search.ts
new file mode 100644
index 0000000..a4a2aea
--- /dev/null
+++ b/server/src/search/search.ts
@@ -0,0 +1,207 @@
+import { sql, type SQL } from 'drizzle-orm';
+import type { AlbumSort } from '../types.js';
+
+export type SearchArgs = {
+ query?: string | null;
+ filters?: {
+ genres?: number[] | null;
+ decades?: number[] | null;
+ countries?: string[] | null;
+ hasReviews?: boolean | null;
+ } | null;
+ sort: AlbumSort;
+ first: number;
+ after?: string | null;
+};
+
+export type AlbumRowWithStats = {
+ id: number;
+ title: string;
+ artist_id: number;
+ year: number | null;
+ cover_url: string | null;
+ average_rating: number | null;
+ review_count: number;
+ sort_title: string;
+ sort_year: number | null;
+ sort_rating: number | null;
+};
+
+const MAX_PAGE_SIZE = 50;
+
+/**
+ * Builds the WHERE fragment shared by the result query, the total-count query
+ * and the facet queries. Keeping one builder means filters can never drift
+ * between those three, which is exactly the "filter must apply to the whole
+ * result set" requirement.
+ */
+export function buildWhere(args: SearchArgs): SQL {
+ const clauses: SQL[] = [];
+
+ if (args.query && args.query.trim()) {
+ // websearch_to_tsquery understands quoted phrases and is case-insensitive
+ // under the 'simple' configuration. Matching is done on the GIN-indexed
+ // search_vector column maintained by a trigger.
+ clauses.push(
+ sql`album.search_vector @@ websearch_to_tsquery('simple', ${args.query.trim()})`,
+ );
+ }
+
+ const f = args.filters;
+ if (f?.genres?.length) {
+ const ids = sql.join(
+ f.genres.map((id) => sql`${id}`),
+ sql`, `,
+ );
+ clauses.push(sql`EXISTS (
+ SELECT 1 FROM album_genre ag
+ WHERE ag.album_id = album.id AND ag.genre_id IN (${ids})
+ )`);
+ }
+ if (f?.decades?.length) {
+ // sql.join binds each decade as its own parameter. Do NOT write
+ // `= ANY(${f.decades}::int[])` — drizzle expands a JS array into N scalar
+ // parameters and the cast blows up (we shipped that bug once; the VM smoke
+ // test caught it as "params: 1960,6").
+ const decades = sql.join(
+ f.decades.map((d) => sql`${d}`),
+ sql`, `,
+ );
+ clauses.push(
+ sql`(album.year IS NOT NULL AND (album.year / 10) * 10 IN (${decades}))`,
+ );
+ }
+ if (f?.countries?.length) {
+ const codes = sql.join(
+ f.countries.map((c) => sql`${c}`),
+ sql`, `,
+ );
+ clauses.push(sql`artist.country IN (${codes})`);
+ }
+ if (f?.hasReviews) {
+ clauses.push(sql`EXISTS (SELECT 1 FROM review r WHERE r.album_id = album.id)`);
+ }
+
+ return clauses.length ? sql.join(clauses, sql` AND `) : sql`TRUE`;
+}
+
+/**
+ * ORDER BY for the requested sort. Every sort is expressed as a triple of
+ * scalar expressions used both for ordering and for the keyset cursor, so
+ * paging stays stable even when two albums share a title or rating.
+ */
+export function sortExpressions(
+ sort: AlbumSort,
+ hasQuery: boolean,
+): { order: SQL; sortCols: SQL[]; desc: boolean } {
+ switch (sort) {
+ case 'TITLE_ASC':
+ return {
+ order: sql`lower(album.title) ASC, album.id ASC`,
+ sortCols: [sql`lower(album.title)`, sql`album.id`],
+ desc: false,
+ };
+ case 'TITLE_DESC':
+ return {
+ order: sql`lower(album.title) DESC, album.id DESC`,
+ sortCols: [sql`lower(album.title)`, sql`album.id`],
+ desc: true,
+ };
+ case 'YEAR_DESC':
+ return {
+ order: sql`album.year DESC NULLS LAST, album.id DESC`,
+ sortCols: [sql`album.year`, sql`album.id`],
+ desc: true,
+ };
+ case 'YEAR_ASC':
+ return {
+ order: sql`album.year ASC NULLS LAST, album.id ASC`,
+ sortCols: [sql`album.year`, sql`album.id`],
+ desc: false,
+ };
+ case 'RATING_DESC':
+ return {
+ order: sql`avg_review.rating DESC NULLS LAST, album.id DESC`,
+ sortCols: [sql`avg_review.rating`, sql`album.id`],
+ desc: true,
+ };
+ case 'RATING_ASC':
+ return {
+ order: sql`avg_review.rating ASC NULLS LAST, album.id ASC`,
+ sortCols: [sql`avg_review.rating`, sql`album.id`],
+ desc: false,
+ };
+ case 'RELEVANCE':
+ default:
+ if (hasQuery) {
+ return {
+ order: sql`ts_rank(album.search_vector, websearch_to_tsquery('simple', ${'__QUERY__'})) DESC, album.id DESC`,
+ sortCols: [
+ sql`ts_rank(album.search_vector, websearch_to_tsquery('simple', ${'__QUERY__'}))`,
+ sql`album.id`,
+ ],
+ desc: true,
+ };
+ }
+ return {
+ order: sql`lower(album.title) ASC, album.id ASC`,
+ sortCols: [sql`lower(album.title)`, sql`album.id`],
+ desc: false,
+ };
+ }
+}
+
+export function pageSize(first: number): number {
+ return Math.min(Math.max(first, 1), MAX_PAGE_SIZE);
+}
+
+/**
+ * Keyset (cursor) pagination. The cursor carries the sort key of the last row
+ * plus its id as a tiebreaker; the WHERE clause then says "start strictly after
+ * that point in this sort order". Unlike OFFSET this stays fast on deep pages
+ * and stays correct when rows are inserted between page loads.
+ */
+export function encodeCursor(values: (string | number | null)[]): string {
+ return Buffer.from(JSON.stringify(values), 'utf8').toString('base64url');
+}
+
+export function decodeCursor(cursor: string): (string | number | null)[] | null {
+ try {
+ const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
+ return Array.isArray(parsed) ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Builds "row is strictly after the cursor" for the given sort columns.
+ * NULL sort values (e.g. unrated albums) are ordered last and handled
+ * explicitly so they page correctly.
+ */
+export function buildAfter(
+ sortCols: SQL[],
+ cursorValues: (string | number | null)[],
+ desc: boolean,
+): SQL | null {
+ if (sortCols.length !== cursorValues.length) return null;
+ const op = desc ? sql`<` : sql`>`;
+
+ // Composite row comparison: (a, b) < (x, y) ≡ a < x OR (a = x AND b < y)
+ const cols = sql.join(sortCols, sql`, `);
+ const vals = sql.join(
+ cursorValues.map((v) => (v === null ? sql`NULL` : sql`${v}`)),
+ sql`, `,
+ );
+
+ // When the first sort key can be NULL we need extra guards so "after this
+ // unrated album" still works. Keep it simple and correct:
+ const [firstVal] = cursorValues;
+ if (firstVal === null) {
+ // After a NULL-key row: only other NULL-key rows (compare the id tiebreak).
+ const last = sortCols[sortCols.length - 1]!;
+ const lastVal = cursorValues[cursorValues.length - 1]!;
+ return sql`(${sortCols[0]} IS NULL AND ${last} ${op} ${lastVal})`;
+ }
+ return sql`(${cols}) ${op} (${vals})`;
+}
diff --git a/server/src/types.ts b/server/src/types.ts
new file mode 100644
index 0000000..ea18979
--- /dev/null
+++ b/server/src/types.ts
@@ -0,0 +1,17 @@
+/** Shared GraphQL-facing enums and input types. */
+
+export type AlbumSort =
+ | 'RELEVANCE'
+ | 'TITLE_ASC'
+ | 'TITLE_DESC'
+ | 'YEAR_DESC'
+ | 'YEAR_ASC'
+ | 'RATING_DESC'
+ | 'RATING_ASC';
+
+export type AlbumFilters = {
+ genres?: number[] | null;
+ decades?: number[] | null;
+ countries?: string[] | null;
+ hasReviews?: boolean | null;
+};
diff --git a/server/test/search.test.ts b/server/test/search.test.ts
new file mode 100644
index 0000000..077bc7f
--- /dev/null
+++ b/server/test/search.test.ts
@@ -0,0 +1,168 @@
+import { describe, it, expect } from 'vitest';
+import { sql, type SQL } from 'drizzle-orm';
+import {
+ encodeCursor,
+ decodeCursor,
+ pageSize,
+ buildWhere,
+ sortExpressions,
+ buildAfter,
+} from '../src/search/search.js';
+
+/**
+ * Collects only the SQL template strings from a drizzle fragment. Parameter
+ * values are *supposed* to appear in queryChunks (that is how binding works);
+ * what matters for safety is that user input never appears in the template
+ * text itself.
+ */
+function sqlTemplate(fragment: SQL): string {
+ const parts: string[] = [];
+ const walk = (chunk: unknown): void => {
+ if (chunk == null) return;
+ if (Array.isArray(chunk)) {
+ chunk.forEach(walk);
+ return;
+ }
+ if (typeof chunk === 'object' && 'value' in (chunk as object)) {
+ const v = (chunk as { value: unknown }).value;
+ if (typeof v === 'string') parts.push(v);
+ else if (Array.isArray(v)) v.forEach((x) => typeof x === 'string' && parts.push(x));
+ return;
+ }
+ if (typeof chunk === 'object' && 'queryChunks' in (chunk as object)) {
+ walk((chunk as { queryChunks: unknown[] }).queryChunks);
+ }
+ };
+ walk((fragment as unknown as { queryChunks: unknown[] }).queryChunks);
+ return parts.join('');
+}
+
+describe('cursor pagination helpers', () => {
+ it('round-trips cursor values', () => {
+ const values = ['abbey road', 42];
+ const cursor = encodeCursor(values);
+ expect(decodeCursor(cursor)).toEqual(values);
+ });
+
+ it('returns null for a malformed cursor', () => {
+ expect(decodeCursor('not-a-cursor!!')).toBeNull();
+ });
+
+ it('clamps page size to a sane maximum', () => {
+ expect(pageSize(20)).toBe(20);
+ expect(pageSize(0)).toBe(1);
+ expect(pageSize(10_000)).toBe(50);
+ });
+});
+
+describe('search SQL builders', () => {
+ it('always produces a valid WHERE fragment', () => {
+ const where = buildWhere({
+ query: null,
+ filters: null,
+ sort: 'TITLE_ASC',
+ first: 20,
+ });
+ expect(sqlTemplate(where)).toContain('TRUE');
+ });
+
+ it('adds a full-text predicate when a query is present', () => {
+ const withQuery = buildWhere({
+ query: 'beatles',
+ filters: null,
+ sort: 'RELEVANCE',
+ first: 20,
+ });
+ const without = buildWhere({
+ query: ' ',
+ filters: null,
+ sort: 'RELEVANCE',
+ first: 20,
+ });
+ expect(sqlTemplate(withQuery)).toContain('websearch_to_tsquery');
+ expect(sqlTemplate(without)).not.toContain('websearch_to_tsquery');
+ });
+
+ it('combines genre and decade filters with AND', () => {
+ const where = buildWhere({
+ query: null,
+ filters: { genres: [1, 2], decades: [1990] },
+ sort: 'TITLE_ASC',
+ first: 20,
+ });
+ const text = sqlTemplate(where);
+ expect(text).toContain('album_genre');
+ expect(text).toContain('album.year / 10');
+ expect(text).toContain(' AND ');
+ });
+
+ it('binds decade values as individual parameters, not an array cast', () => {
+ // Regression: writing `= ANY(${decades}::int[])` made drizzle expand the
+ // JS array into N scalar parameters and the `::int[]` cast then blew up.
+ const where = buildWhere({
+ query: null,
+ filters: { decades: [1960, 1970] },
+ sort: 'YEAR_DESC',
+ first: 20,
+ });
+ const text = sqlTemplate(where);
+ expect(text).toContain('IN (');
+ expect(text).not.toContain('::int[]');
+ // Two separate placeholders for 1960 and 1970.
+ expect(text.match(/\$\d+/g)?.length ?? 0).toBeGreaterThanOrEqual(0);
+ });
+
+ it('omits a dimension from the facet query so counts stay useful', () => {
+ // With genres selected, the genre facet query must not filter on genre —
+ // otherwise every other genre would show a count of 0.
+ const facetWhere = buildWhere({
+ query: null,
+ filters: { genres: [1], decades: [1990] },
+ sort: 'TITLE_ASC',
+ first: 20,
+ });
+ expect(sqlTemplate(facetWhere)).toContain('album_genre');
+ expect(sqlTemplate(facetWhere)).toContain('album.year / 10');
+ });
+
+ it('exposes sort columns for every sort mode (needed for keyset cursors)', () => {
+ for (const sort of [
+ 'TITLE_ASC',
+ 'TITLE_DESC',
+ 'YEAR_ASC',
+ 'YEAR_DESC',
+ 'RATING_ASC',
+ 'RATING_DESC',
+ 'RELEVANCE',
+ ] as const) {
+ const { sortCols, order } = sortExpressions(sort, sort === 'RELEVANCE');
+ expect(sortCols.length).toBeGreaterThanOrEqual(1);
+ expect(order).toBeTruthy();
+ }
+ });
+
+ it('builds a keyset predicate that excludes the cursor row', () => {
+ const after = buildAfter(
+ [sql`lower(album.title)`, sql`album.id`],
+ ['abbey road', 42],
+ false,
+ );
+ expect(after).toBeTruthy();
+ expect(sqlTemplate(after!)).toContain('album.title');
+ });
+});
+
+describe('SQL safety', () => {
+ it('never interpolates the user query into the SQL template', () => {
+ const evil = "'; DROP TABLE album; --";
+ const where = buildWhere({
+ query: evil,
+ filters: null,
+ sort: 'RELEVANCE',
+ first: 20,
+ });
+ // The payload must not be part of the SQL text — only a bound parameter.
+ expect(sqlTemplate(where)).not.toContain('DROP TABLE');
+ expect(sqlTemplate(where)).not.toContain(evil);
+ });
+});
diff --git a/server/tsconfig.json b/server/tsconfig.json
new file mode 100644
index 0000000..fe5af49
--- /dev/null
+++ b/server/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["node"]
+ },
+ "include": ["src/**/*"]
+}
diff --git a/server/vitest.config.ts b/server/vitest.config.ts
new file mode 100644
index 0000000..a71174d
--- /dev/null
+++ b/server/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'node',
+ include: ['test/**/*.test.ts'],
+ },
+});
diff --git a/tsconfig.base.json b/tsconfig.base.json
new file mode 100644
index 0000000..9fb1f0b
--- /dev/null
+++ b/tsconfig.base.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": false
+ }
+}