From 721c78ee5b75b0a868b0e5036d79934125bca2a1 Mon Sep 17 00:00:00 2001 From: Even Date: Sat, 10 Oct 2026 14:33:43 +0200 Subject: [PATCH 1/2] feat: #6 GraphQL Yoga server with searchAlbums - search/search.ts: websearch_to_tsquery('simple', ...) against the GIN-indexed search_vector, facet filters that drop their own dimension, and keyset cursors. Pure and unit-tested. - loaders/index.ts: per-request DataLoaders for Album.artists and Album.genres, so a page costs 6 queries whatever its size. - Drizzle migration for the catalog tables and a 16-album seed. - docs/VM-setup.md: run steps and the curl smoke suite. - Root build now builds server/ too, since the deploy job expects server/dist/index.js once server/ exists. --- docs/VM-setup.md | 64 ++ package.json | 2 +- pnpm-lock.yaml | 1398 +++++++++++++++++++++++- pnpm-workspace.yaml | 6 +- server/drizzle.config.ts | 9 + server/drizzle/0000_catalog.sql | 35 + server/drizzle/meta/0000_snapshot.json | 226 ++++ server/drizzle/meta/_journal.json | 13 + server/package.json | 31 + server/src/db/client.ts | 15 + server/src/db/migrate.ts | 10 + server/src/db/schema.ts | 68 ++ server/src/db/seed.ts | 106 ++ server/src/index.ts | 21 + server/src/loaders/index.ts | 55 + server/src/resolvers.ts | 94 ++ server/src/search/search.test.ts | 111 ++ server/src/search/search.ts | 154 +++ server/tsconfig.build.json | 4 + server/tsconfig.json | 11 + 20 files changed, 2414 insertions(+), 19 deletions(-) create mode 100644 docs/VM-setup.md create mode 100644 server/drizzle.config.ts create mode 100644 server/drizzle/0000_catalog.sql create mode 100644 server/drizzle/meta/0000_snapshot.json create mode 100644 server/drizzle/meta/_journal.json create mode 100644 server/package.json create mode 100644 server/src/db/client.ts create mode 100644 server/src/db/migrate.ts create mode 100644 server/src/db/schema.ts create mode 100644 server/src/db/seed.ts create mode 100644 server/src/index.ts create mode 100644 server/src/loaders/index.ts create mode 100644 server/src/resolvers.ts create mode 100644 server/src/search/search.test.ts create mode 100644 server/src/search/search.ts create mode 100644 server/tsconfig.build.json create mode 100644 server/tsconfig.json diff --git a/docs/VM-setup.md b/docs/VM-setup.md new file mode 100644 index 0000000..5bf4237 --- /dev/null +++ b/docs/VM-setup.md @@ -0,0 +1,64 @@ +# VM setup + +How to run the GraphQL API (`server/`) on the group VM and check that it works. + +## Run the API + +`DATABASE_URL` must point at the `crate` database, for example in `server/.env`: + +``` +DATABASE_URL=postgres://t19:@127.0.0.1:5432/crate +``` + +```bash +pnpm install +pnpm --filter @t19/server db:migrate # create the catalog tables +pnpm --filter @t19/server db:seed # replace the catalog with the sample albums +pnpm --filter @t19/server build +pnpm --filter @t19/server start # http://localhost:3001/graphql +``` + +## Smoke suite + +Run on the VM after a deploy. Each command prints what to expect. The +expectations assume the sample data from `db:seed`. + +```bash +API=http://localhost:3001/graphql +gql() { curl -s "$API" -H 'content-type: application/json' -d "$1"; echo; } +``` + +1. The API answers. Expect `{"data":{"__typename":"Query"}}`. + +```bash +gql '{"query":"{ __typename }"}' +``` + +2. Search is case-insensitive. Expect `totalCount` 4 for both. + +```bash +gql '{"query":"{ a: searchAlbums(input:{query:\"blue\",sort:RELEVANCE},first:5){ totalCount } b: searchAlbums(input:{query:\"BLUE\",sort:RELEVANCE},first:5){ totalCount } }"}' +``` + +3. A single word also matches artist names, and artists and genres resolve. + Expect A Love Supreme and Blue Train, both by John Coltrane. + +```bash +gql '{"query":"{ searchAlbums(input:{query:\"coltrane\",sort:TITLE_ASC},first:5){ totalCount edges { node { title releaseYear artists { name } genres { name } } } } }"}' +``` + +4. Facets ignore their own filter. With Rock (id 4) selected, expect + `totalCount` 7, `genreFacets` still listing all seven genres, and + `decadeFacets` counting only Rock albums. + +```bash +gql '{"query":"{ searchAlbums(input:{query:\"\",genreIds:[\"4\"],sort:TITLE_ASC},first:5){ totalCount genreFacets { genre { name } count } decadeFacets { decade count } } }"}' +``` + +5. Paging. Expect `hasNextPage` true and an `endCursor`. Paste the cursor in as + `after` to get the next two albums, with no album repeated. + +```bash +gql '{"query":"{ searchAlbums(input:{query:\"\",sort:RELEASE_YEAR_DESC},first:2){ edges { node { title releaseYear } } pageInfo { hasNextPage endCursor } } }"}' +gql '{"query":"{ searchAlbums(input:{query:\"\",sort:RELEASE_YEAR_DESC},first:2,after:\"\"){ edges { node { title releaseYear } } pageInfo { hasNextPage endCursor } } }"}' +``` diff --git a/package.json b/package.json index 2ade98e..efb10c0 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "dev": "pnpm --filter @t19/client dev", - "build": "pnpm --filter @t19/client build", + "build": "pnpm -r build", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01561b1..33288d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,7 +53,7 @@ importers: 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(jsdom@29.1.1)(vite@8.3.1)) + version: 7.0.1(@testing-library/dom@10.4.2)(vitest@4.1.11(@types/node@24.19.2)(jsdom@29.1.1)(vite@8.3.1(@types/node@24.19.2)(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) @@ -65,7 +65,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.1.0 - version: 6.1.1(vite@8.3.1) + version: 6.1.1(vite@8.3.1(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)) jsdom: specifier: ^29.1.1 version: 29.1.1 @@ -74,10 +74,47 @@ importers: version: 5.9.3 vite: specifier: ^8.2.2 - version: 8.3.1 + version: 8.3.1(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15) vitest: specifier: ^4.1.11 - version: 4.1.11(jsdom@29.1.1)(vite@8.3.1) + version: 4.1.11(@types/node@24.19.2)(jsdom@29.1.1)(vite@8.3.1(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)) + + server: + dependencies: + dataloader: + specifier: ^2.2.3 + version: 2.2.3 + drizzle-orm: + specifier: ^0.45.4 + version: 0.45.4(@types/pg@8.23.1)(pg@8.23.1) + graphql: + specifier: ^16.14.2 + version: 16.14.2 + graphql-yoga: + specifier: ^5.24.4 + version: 5.24.4(graphql@16.14.2) + pg: + specifier: ^8.23.1 + version: 8.23.1 + devDependencies: + '@types/node': + specifier: ^24.19.2 + version: 24.19.2 + '@types/pg': + specifier: ^8.23.1 + version: 8.23.1 + drizzle-kit: + specifier: ^0.31.11 + version: 0.31.11 + tsx: + specifier: ^4.23.15 + 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.19.2)(jsdom@29.1.1)(vite@8.3.1(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)) packages: @@ -210,6 +247,473 @@ packages: resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} 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} @@ -257,6 +761,50 @@ packages: '@noble/hashes': optional: true + '@fastify/busboy@3.2.3': + resolution: {integrity: sha512-fmo/IKgkenPzz3qTKG+sIUXdF/Kv83AkvvOXJHPTHJeKJbat8SsL1ihUaFaDotqoEl4UZ1Lf8Eb/3vK6eaWX2A==} + + '@graphql-tools/executor@2.0.3': + resolution: {integrity: sha512-0qUCtalTDdanDuvW4EiHnNMct8QHYKTuGjI2lXU388LrpoV+5tlqp/xPqdYbrDtl3OBpZHToOEBcQunWz1UDig==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/merge@9.2.6': + resolution: {integrity: sha512-M7iI/qoCnks6iITkimtdZgI2SrWOInr+QRWuhEsInaumzQGPd0pnWbRAVTRVcFN2Gu9hM2aS5kYzASC93d9epw==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/schema@10.1.3': + resolution: {integrity: sha512-N1ZpzguxzqTy5fpurqWQ5BXyjirlrtvVLeSafsu+SCDNjDeRnVxf9n5PVbdgzwFPAmP/7NK9sq9UB/kyR8oFiw==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/utils@12.0.3': + resolution: {integrity: sha512-M+04bHvI1SKoug7W2EkwfKjig2mdtgIIlFY8p2jLHmxPNVs5bC/3R+58JrD+7ZXWgPILWTwuqJ9AqTjPlahroA==} + 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'} @@ -296,6 +844,9 @@ packages: '@oxc-project/types@0.151.0': resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + '@repeaterjs/repeater@3.1.0': + resolution: {integrity: sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==} + '@rolldown/binding-android-arm-eabi@1.2.11': resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -442,6 +993,12 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@24.19.2': + resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} + + '@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: @@ -554,6 +1111,38 @@ packages: '@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/disposablestack@0.1.0': + resolution: {integrity: sha512-ok0A9+D+mdeb3w1GNCpVr+2gVVLRWKo9Ywm/whNsoWSNfNg3jNxS0/wUF6NSUYbfOtFGu2wZgJnLjasbaQEfvA==} + engines: {node: '>=22.15.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/promise-helpers@2.0.0': + resolution: {integrity: sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==} + engines: {node: '>=22.15.0'} + + '@whatwg-node/server@0.11.0': + resolution: {integrity: sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ==} + engines: {node: '>=18.0.0'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -659,6 +1248,9 @@ packages: 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'} @@ -699,6 +1291,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + 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'} @@ -732,6 +1328,9 @@ packages: 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'} @@ -769,6 +1368,105 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + drizzle-kit@0.31.11: + resolution: {integrity: sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==} + hasBin: true + + drizzle-orm@0.45.4: + resolution: {integrity: sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ==} + 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'} @@ -818,6 +1516,21 @@ packages: 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'} @@ -975,6 +1688,9 @@ packages: 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'} @@ -995,6 +1711,16 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graphql-yoga@5.24.4: + resolution: {integrity: sha512-jnpLm2g9BfnP18VKL+Yao8YRQYgDjXdAkFgZso/tnKFcRcAtOeUKLDVc1XnYiE3uooXzfIQzsvpKR+fC3we4QA==} + 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'} @@ -1295,6 +2021,9 @@ packages: 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} @@ -1400,6 +2129,40 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-cloudflare@1.4.1: + resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} + + pg-connection-string@2.14.1: + resolution: {integrity: sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==} + + 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.1: + resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.1: + resolution: {integrity: sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==} + 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==} @@ -1415,6 +2178,22 @@ packages: 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'} @@ -1464,6 +2243,9 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rolldown@1.2.11: resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1540,6 +2322,17 @@ packages: 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==} @@ -1617,6 +2410,14 @@ packages: 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'} @@ -1653,6 +2454,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.30.0: resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} engines: {node: '>=20.18.1'} @@ -1666,6 +2470,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + vite@8.3.1: resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1803,6 +2610,10 @@ packages: 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'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1973,6 +2784,257 @@ snapshots: '@csstools/css-tokenizer@4.0.2': {} + '@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 @@ -2021,6 +3083,59 @@ snapshots: '@exodus/bytes@1.16.0': {} + '@fastify/busboy@3.2.3': {} + + '@graphql-tools/executor@2.0.3(graphql@16.14.2)': + dependencies: + '@graphql-tools/utils': 12.0.3(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.1.0 + '@whatwg-node/promise-helpers': 2.0.0 + graphql: 16.14.2 + tslib: 2.8.1 + + '@graphql-tools/merge@9.2.6(graphql@16.14.2)': + dependencies: + '@graphql-tools/utils': 12.0.3(graphql@16.14.2) + graphql: 16.14.2 + tslib: 2.8.1 + + '@graphql-tools/schema@10.1.3(graphql@16.14.2)': + dependencies: + '@graphql-tools/merge': 9.2.6(graphql@16.14.2) + '@graphql-tools/utils': 12.0.3(graphql@16.14.2) + graphql: 16.14.2 + tslib: 2.8.1 + + '@graphql-tools/utils@12.0.3(graphql@16.14.2)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) + '@whatwg-node/promise-helpers': 2.0.0 + 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 @@ -2058,6 +3173,8 @@ snapshots: '@oxc-project/types@0.151.0': {} + '@repeaterjs/repeater@3.1.0': {} + '@rolldown/binding-android-arm-eabi@1.2.11': optional: true @@ -2118,7 +3235,7 @@ snapshots: 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(jsdom@29.1.1)(vite@8.3.1))': + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@4.1.11(@types/node@24.19.2)(jsdom@29.1.1)(vite@8.3.1(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)))': dependencies: '@adobe/css-tools': 4.5.0 '@testing-library/dom': 10.4.2 @@ -2128,7 +3245,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 4.1.11(jsdom@29.1.1)(vite@8.3.1) + vitest: 4.1.11(@types/node@24.19.2)(jsdom@29.1.1)(vite@8.3.1(@types/node@24.19.2)(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: @@ -2153,6 +3270,16 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@24.19.2': + dependencies: + undici-types: 7.24.6 + + '@types/pg@8.23.1': + dependencies: + '@types/node': 24.19.2 + pg-protocol: 1.16.1 + pg-types: 2.2.0 + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: '@types/react': 19.3.0 @@ -2252,10 +3379,10 @@ snapshots: '@typescript-eslint/types': 8.70.1 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.1.1(vite@8.3.1)': + '@vitejs/plugin-react@6.1.1(vite@8.3.1(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.1 + vite: 8.3.1(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15) '@vitest/expect@4.1.11': dependencies: @@ -2266,13 +3393,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.3.1)': + '@vitest/mocker@4.1.11(vite@8.3.1(@types/node@24.19.2)(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: - vite: 8.3.1 + vite: 8.3.1(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15) '@vitest/pretty-format@4.1.11': dependencies: @@ -2298,6 +3425,48 @@ snapshots: 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/disposablestack@0.1.0': + dependencies: + '@whatwg-node/promise-helpers': 2.0.0 + 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.3 + '@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/promise-helpers@2.0.0': + 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 + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: acorn: 8.18.0 @@ -2408,6 +3577,8 @@ snapshots: node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.1) + buffer-from@1.1.2: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2446,6 +3617,10 @@ snapshots: convert-source-map@2.0.0: {} + cross-inspect@1.0.1: + dependencies: + tslib: 2.8.1 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2488,6 +3663,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dataloader@2.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2516,6 +3693,18 @@ snapshots: dom-accessibility-api@0.6.3: {} + 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.4(@types/pg@8.23.1)(pg@8.23.1): + optionalDependencies: + '@types/pg': 8.23.1 + pg: 8.23.1 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2622,6 +3811,89 @@ snapshots: 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: {} @@ -2815,6 +4087,10 @@ snapshots: 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 @@ -2830,6 +4106,24 @@ snapshots: gopd@1.2.0: {} + graphql-yoga@5.24.4(graphql@16.14.2): + dependencies: + '@envelop/core': 5.6.1 + '@envelop/instrumentation': 1.0.1 + '@graphql-tools/executor': 2.0.3(graphql@16.14.2) + '@graphql-tools/schema': 10.1.3(graphql@16.14.2) + '@graphql-tools/utils': 12.0.3(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: {} @@ -3114,6 +4408,8 @@ snapshots: lodash.merge@4.6.2: {} + lru-cache@10.4.3: {} + lru-cache@11.5.3: {} lru-cache@5.1.1: @@ -3215,6 +4511,41 @@ snapshots: pathe@2.0.3: {} + pg-cloudflare@1.4.1: + optional: true + + pg-connection-string@2.14.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.1): + dependencies: + pg: 8.23.1 + + pg-protocol@1.16.1: {} + + 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.1: + dependencies: + pg-connection-string: 2.14.1 + pg-pool: 3.14.0(pg@8.23.1) + pg-protocol: 1.16.1 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.1 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@4.0.7: {} @@ -3227,6 +4558,16 @@ snapshots: 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: {} @@ -3277,6 +4618,8 @@ snapshots: resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: {} + rolldown@1.2.11: dependencies: '@oxc-project/types': 0.151.0 @@ -3387,6 +4730,15 @@ snapshots: 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: {} std-env@4.2.0: {} @@ -3467,6 +4819,14 @@ snapshots: 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 @@ -3523,6 +4883,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@7.24.6: {} + undici@7.30.0: {} update-browserslist-db@1.3.3(browserslist@4.29.1): @@ -3535,7 +4897,9 @@ snapshots: dependencies: punycode: 2.3.1 - vite@8.3.1: + urlpattern-polyfill@10.1.0: {} + + vite@8.3.1(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -3543,12 +4907,15 @@ snapshots: rolldown: 1.2.11 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 24.19.2 + esbuild: 0.28.2 fsevents: 2.3.3 + tsx: 4.23.15 - vitest@4.1.11(jsdom@29.1.1)(vite@8.3.1): + vitest@4.1.11(@types/node@24.19.2)(jsdom@29.1.1)(vite@8.3.1(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.3.1) + '@vitest/mocker': 4.1.11(vite@8.3.1(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -3565,9 +4932,10 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.3.1 + vite: 8.3.1(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 24.19.2 jsdom: 29.1.1 transitivePeerDependencies: - msw @@ -3644,6 +5012,8 @@ snapshots: xmlchars@2.2.0: {} + xtend@4.0.2: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 69918e0..d47151d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,4 @@ -# Workspace packages. -# Only client/ exists at M1 — the spec and mockup milestone. server/ joins in -# M2 (database + GraphQL backend) and e2e/ in M4 (Playwright). Declaring them -# now as empty packages would just be noise in review. +# Workspace packages. e2e/ (Playwright) joins in M4. packages: - 'client' + - 'server' diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts new file mode 100644 index 0000000..9c7f689 --- /dev/null +++ b/server/drizzle.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'drizzle-kit'; + +// `pnpm db:generate` diffs src/db/schema.ts against drizzle/ and writes the +// next SQL migration. It does not connect to a database. +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', +}); diff --git a/server/drizzle/0000_catalog.sql b/server/drizzle/0000_catalog.sql new file mode 100644 index 0000000..15db537 --- /dev/null +++ b/server/drizzle/0000_catalog.sql @@ -0,0 +1,35 @@ +CREATE TABLE "album" ( + "id" serial PRIMARY KEY NOT NULL, + "title" text NOT NULL, + "release_year" integer, + "search_vector" "tsvector" +); +--> statement-breakpoint +CREATE TABLE "album_artist" ( + "album_id" integer NOT NULL, + "artist_id" integer NOT NULL, + CONSTRAINT "album_artist_album_id_artist_id_pk" PRIMARY KEY("album_id","artist_id") +); +--> statement-breakpoint +CREATE TABLE "album_genre" ( + "album_id" integer NOT NULL, + "genre_id" integer NOT NULL, + CONSTRAINT "album_genre_album_id_genre_id_pk" PRIMARY KEY("album_id","genre_id") +); +--> statement-breakpoint +CREATE TABLE "artist" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "genre" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + CONSTRAINT "genre_name_unique" UNIQUE("name") +); +--> statement-breakpoint +ALTER TABLE "album_artist" ADD CONSTRAINT "album_artist_album_id_album_id_fk" FOREIGN KEY ("album_id") REFERENCES "public"."album"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "album_artist" ADD CONSTRAINT "album_artist_artist_id_artist_id_fk" FOREIGN KEY ("artist_id") REFERENCES "public"."artist"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "album_genre" ADD CONSTRAINT "album_genre_album_id_album_id_fk" FOREIGN KEY ("album_id") REFERENCES "public"."album"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "album_genre" ADD CONSTRAINT "album_genre_genre_id_genre_id_fk" FOREIGN KEY ("genre_id") REFERENCES "public"."genre"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "album_search_vector_idx" ON "album" USING gin ("search_vector"); \ No newline at end of file diff --git a/server/drizzle/meta/0000_snapshot.json b/server/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..b6a115b --- /dev/null +++ b/server/drizzle/meta/0000_snapshot.json @@ -0,0 +1,226 @@ +{ + "id": "3d3f861d-1cfa-4d97-89d0-168270b3279c", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.album": { + "name": "album", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "release_year": { + "name": "release_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "album_search_vector_idx": { + "name": "album_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.album_artist": { + "name": "album_artist", + "schema": "", + "columns": { + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "album_artist_album_id_album_id_fk": { + "name": "album_artist_album_id_album_id_fk", + "tableFrom": "album_artist", + "tableTo": "album", + "columnsFrom": ["album_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "album_artist_artist_id_artist_id_fk": { + "name": "album_artist_artist_id_artist_id_fk", + "tableFrom": "album_artist", + "tableTo": "artist", + "columnsFrom": ["artist_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "album_artist_album_id_artist_id_pk": { + "name": "album_artist_album_id_artist_id_pk", + "columns": ["album_id", "artist_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.album_genre": { + "name": "album_genre", + "schema": "", + "columns": { + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "album_genre_album_id_album_id_fk": { + "name": "album_genre_album_id_album_id_fk", + "tableFrom": "album_genre", + "tableTo": "album", + "columnsFrom": ["album_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "album_genre_genre_id_genre_id_fk": { + "name": "album_genre_genre_id_genre_id_fk", + "tableFrom": "album_genre", + "tableTo": "genre", + "columnsFrom": ["genre_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "album_genre_album_id_genre_id_pk": { + "name": "album_genre_album_id_genre_id_pk", + "columns": ["album_id", "genre_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artist": { + "name": "artist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.genre": { + "name": "genre", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "genre_name_unique": { + "name": "genre_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json new file mode 100644 index 0000000..8f92311 --- /dev/null +++ b/server/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1791633902337, + "tag": "0000_catalog", + "breakpoints": true + } + ] +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..2526c92 --- /dev/null +++ b/server/package.json @@ -0,0 +1,31 @@ +{ + "name": "@t19/server", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch --env-file-if-exists=.env src/index.ts", + "build": "tsc -p tsconfig.build.json", + "start": "node --env-file-if-exists=.env dist/index.js", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx --env-file-if-exists=.env src/db/migrate.ts", + "db:seed": "tsx --env-file-if-exists=.env src/db/seed.ts" + }, + "dependencies": { + "dataloader": "^2.2.3", + "drizzle-orm": "^0.45.4", + "graphql": "^16.14.2", + "graphql-yoga": "^5.24.4", + "pg": "^8.23.1" + }, + "devDependencies": { + "@types/node": "^24.19.2", + "@types/pg": "^8.23.1", + "drizzle-kit": "^0.31.11", + "tsx": "^4.23.15", + "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..a05e69d --- /dev/null +++ b/server/src/db/client.ts @@ -0,0 +1,15 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; +import pg from 'pg'; + +const url = process.env.DATABASE_URL; +if (!url) { + throw new Error( + 'DATABASE_URL is not set, e.g. postgres://t19:@127.0.0.1:5432/crate', + ); +} + +// One pool for the whole process. Every request borrows a connection from it. +export const pool = new pg.Pool({ connectionString: url }); +export const db = drizzle(pool); + +export type Db = typeof db; diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts new file mode 100644 index 0000000..be21037 --- /dev/null +++ b/server/src/db/migrate.ts @@ -0,0 +1,10 @@ +import { fileURLToPath } from 'node:url'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { db, pool } from './client.js'; + +// Applies every migration in server/drizzle/ that the database has not seen yet. +await migrate(db, { + migrationsFolder: fileURLToPath(new URL('../../drizzle', import.meta.url)), +}); +await pool.end(); +console.log('Migrations applied.'); diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts new file mode 100644 index 0000000..8308888 --- /dev/null +++ b/server/src/db/schema.ts @@ -0,0 +1,68 @@ +import { + customType, + index, + integer, + pgTable, + primaryKey, + serial, + text, +} from 'drizzle-orm/pg-core'; + +// Drizzle has no built-in tsvector type. +const tsvector = customType<{ data: string }>({ dataType: () => 'tsvector' }); + +/** + * The catalog tables that searchAlbums reads. Reviews and the crate are added + * by their own issues. + */ +export const album = pgTable( + 'album', + { + id: serial('id').primaryKey(), + title: text('title').notNull(), + releaseYear: integer('release_year'), + // The words a search matches: the title plus the artist names, built with + // to_tsvector('simple', ...) when albums are inserted (see seed.ts). The + // 'simple' configuration lowercases every word and does no stemming, which + // is what makes the search case-insensitive and word-based. + searchVector: tsvector('search_vector'), + }, + // GIN is the index type for tsvector, so a word lookup does not scan the table. + (t) => [index('album_search_vector_idx').using('gin', t.searchVector)], +); + +export const artist = pgTable('artist', { + id: serial('id').primaryKey(), + name: text('name').notNull(), +}); + +export const genre = pgTable('genre', { + id: serial('id').primaryKey(), + name: text('name').notNull().unique(), +}); + +export const albumArtist = pgTable( + 'album_artist', + { + albumId: integer('album_id') + .notNull() + .references(() => album.id, { onDelete: 'cascade' }), + artistId: integer('artist_id') + .notNull() + .references(() => artist.id, { onDelete: 'cascade' }), + }, + (t) => [primaryKey({ columns: [t.albumId, t.artistId] })], +); + +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] })], +); diff --git a/server/src/db/seed.ts b/server/src/db/seed.ts new file mode 100644 index 0000000..bb8533e --- /dev/null +++ b/server/src/db/seed.ts @@ -0,0 +1,106 @@ +import { sql } from 'drizzle-orm'; +import { db, pool } from './client.js'; +import { album, albumArtist, albumGenre, artist, genre } from './schema.js'; + +/** + * A small hand-written catalog, enough to exercise search, filters, facets and + * paging. Replaces whatever catalog is already in the database. + */ +const albums: { + title: string; + year: number | null; + artists: string[]; + genres: string[]; +}[] = [ + { title: 'Kind of Blue', year: 1959, artists: ['Miles Davis'], genres: ['Jazz'] }, + { title: 'Blue Train', year: 1957, artists: ['John Coltrane'], genres: ['Jazz'] }, + { title: 'A Love Supreme', year: 1965, artists: ['John Coltrane'], genres: ['Jazz'] }, + { + title: 'Ella and Louis', + year: 1956, + artists: ['Ella Fitzgerald', 'Louis Armstrong'], + genres: ['Jazz'], + }, + { title: 'Blue', year: 1971, artists: ['Joni Mitchell'], genres: ['Folk', 'Pop'] }, + { title: 'Abbey Road', year: 1969, artists: ['The Beatles'], genres: ['Rock', 'Pop'] }, + { title: 'Revolver', year: 1966, artists: ['The Beatles'], genres: ['Rock', 'Pop'] }, + { title: 'Rumours', year: 1977, artists: ['Fleetwood Mac'], genres: ['Rock', 'Pop'] }, + { + title: 'The Dark Side of the Moon', + year: 1973, + artists: ['Pink Floyd'], + genres: ['Rock'], + }, + { + title: 'OK Computer', + year: 1997, + artists: ['Radiohead'], + genres: ['Rock', 'Electronic'], + }, + { title: 'Kid A', year: 2000, artists: ['Radiohead'], genres: ['Electronic', 'Rock'] }, + { title: 'Discovery', year: 2001, artists: ['Daft Punk'], genres: ['Electronic'] }, + { + title: 'Blue Lines', + year: 1991, + artists: ['Massive Attack'], + genres: ['Electronic'], + }, + { + title: 'The Miseducation of Lauryn Hill', + year: 1998, + artists: ['Lauryn Hill'], + genres: ['Hip hop', 'Soul'], + }, + { title: 'Illmatic', year: 1994, artists: ['Nas'], genres: ['Hip hop'] }, + { + title: 'Untitled Demo Tape', + year: null, + artists: ['Unknown Artist'], + genres: ['Rock'], + }, +]; + +await db.transaction(async (tx) => { + await tx.execute(sql`TRUNCATE album, artist, genre RESTART IDENTITY CASCADE`); + + const artistNames = [...new Set(albums.flatMap((a) => a.artists))]; + const genreNames = [...new Set(albums.flatMap((a) => a.genres))]; + const artistRows = await tx + .insert(artist) + .values(artistNames.map((name) => ({ name }))) + .returning(); + const genreRows = await tx + .insert(genre) + .values(genreNames.map((name) => ({ name }))) + .returning(); + const artistId = new Map(artistRows.map((r) => [r.name, r.id])); + const genreId = new Map(genreRows.map((r) => [r.name, r.id])); + + for (const a of albums) { + const [row] = await tx + .insert(album) + .values({ title: a.title, releaseYear: a.year }) + .returning({ id: album.id }); + const albumId = row!.id; + await tx + .insert(albumArtist) + .values(a.artists.map((n) => ({ albumId, artistId: artistId.get(n)! }))); + await tx + .insert(albumGenre) + .values(a.genres.map((n) => ({ albumId, genreId: genreId.get(n)! }))); + } + + // Build the search document from the title and every artist name, so a + // search for "coltrane" finds his albums too. + await tx.execute(sql` + UPDATE album SET search_vector = to_tsvector('simple', + album.title || ' ' || coalesce(( + SELECT string_agg(artist.name, ' ') + FROM album_artist JOIN artist ON artist.id = album_artist.artist_id + WHERE album_artist.album_id = album.id + ), '')) + `); +}); + +await pool.end(); +console.log(`Seeded ${albums.length} albums.`); diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..5a41b96 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,21 @@ +import { readFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { createSchema, createYoga } from 'graphql-yoga'; +import { db } from './db/client.js'; +import { createLoaders } from './loaders/index.js'; +import { resolvers, type Context } from './resolvers.js'; + +// The API contract is the schema.graphql at the repository root, shared with +// the client. The path is the same from src/ (dev) and dist/ (production). +const typeDefs = readFileSync(new URL('../../schema.graphql', import.meta.url), 'utf8'); + +const yoga = createYoga({ + schema: createSchema({ typeDefs, resolvers }), + // New loaders per request, so batching never leaks between requests. + context: () => ({ db, loaders: createLoaders(db) }), +}); + +const port = Number(process.env.PORT ?? 3001); +createServer(yoga).listen(port, () => { + console.log(`Crate API listening on http://localhost:${port}/graphql`); +}); diff --git a/server/src/loaders/index.ts b/server/src/loaders/index.ts new file mode 100644 index 0000000..e891f0b --- /dev/null +++ b/server/src/loaders/index.ts @@ -0,0 +1,55 @@ +import DataLoader from 'dataloader'; +import { asc, eq, inArray } from 'drizzle-orm'; +import type { Db } from '../db/client.js'; +import { albumArtist, albumGenre, artist, genre } from '../db/schema.js'; + +type Named = { id: number; name: string }; + +/** + * DataLoaders for the Album relation fields. Without them a page of 20 albums + * would run one artists query and one genres query per album (N+1). A loader + * collects every album id asked for while resolving the page and runs a + * single query for all of them. + * + * Created fresh for every request (see index.ts), so nothing is cached between + * requests or shared between users. + */ +export function createLoaders(db: Db) { + return { + artistsByAlbum: new DataLoader(async (albumIds) => { + const rows = await db + .select({ albumId: albumArtist.albumId, id: artist.id, name: artist.name }) + .from(albumArtist) + .innerJoin(artist, eq(artist.id, albumArtist.artistId)) + .where(inArray(albumArtist.albumId, [...albumIds])) + .orderBy(asc(artist.id)); + return groupByAlbum(albumIds, rows); + }), + + genresByAlbum: new DataLoader(async (albumIds) => { + const rows = await db + .select({ albumId: albumGenre.albumId, id: genre.id, name: genre.name }) + .from(albumGenre) + .innerJoin(genre, eq(genre.id, albumGenre.genreId)) + .where(inArray(albumGenre.albumId, [...albumIds])) + .orderBy(asc(genre.name)); + return groupByAlbum(albumIds, rows); + }), + }; +} + +export type Loaders = ReturnType; + +// DataLoader needs one result per key, in the same order as the keys. +function groupByAlbum( + albumIds: readonly number[], + rows: (Named & { albumId: number })[], +): Named[][] { + const byAlbum = new Map(); + for (const { albumId, id, name } of rows) { + const list = byAlbum.get(albumId) ?? []; + list.push({ id, name }); + byAlbum.set(albumId, list); + } + return albumIds.map((id) => byAlbum.get(id) ?? []); +} diff --git a/server/src/resolvers.ts b/server/src/resolvers.ts new file mode 100644 index 0000000..80b8f35 --- /dev/null +++ b/server/src/resolvers.ts @@ -0,0 +1,94 @@ +import { sql } from 'drizzle-orm'; +import type { Db } from './db/client.js'; +import type { Loaders } from './loaders/index.js'; +import { + afterCursor, + decodeCursor, + encodeCursor, + orderBy, + pageSize, + searchFilter, + sortKey, + type AlbumSearchInput, +} from './search/search.js'; + +export type Context = { db: Db; loaders: Loaders }; + +type AlbumRow = { id: number; title: string; releaseYear: number | null }; + +type SearchArgs = { input: AlbumSearchInput; first: number; after?: string | null }; + +/** + * searchAlbums runs four queries, all filtered and sorted in SQL so they + * cover the whole result set: one page of albums, the total count, and the two + * facet lists. Album artists and genres are then fetched by the loaders, one + * query each for the whole page. + */ +async function searchAlbums(db: Db, { input, first, after }: SearchArgs) { + const limit = pageSize(first); + const filter = searchFilter(input); + const key = sortKey(input); + const afterSql = after ? afterCursor(key, decodeCursor(after, input.sort)) : sql`TRUE`; + + const [page, total, genreFacets, decadeFacets] = await Promise.all([ + // One extra row tells us whether there is a next page. + db.execute(sql` + SELECT album.id, album.title, album.release_year AS "releaseYear", ${key.expr} AS "sortValue" + FROM album + WHERE ${filter} AND ${afterSql} + ORDER BY ${orderBy(key)} + LIMIT ${limit + 1} + `), + db.execute<{ count: number }>(sql` + SELECT count(*)::int AS count FROM album WHERE ${filter} + `), + db.execute<{ id: number; name: string; count: number }>(sql` + SELECT genre.id, genre.name, count(*)::int AS count + FROM album + JOIN album_genre ON album_genre.album_id = album.id + JOIN genre ON genre.id = album_genre.genre_id + WHERE ${searchFilter(input, 'genres')} + GROUP BY genre.id, genre.name + ORDER BY count DESC, genre.name + `), + db.execute<{ decade: number; count: number }>(sql` + SELECT (album.release_year / 10) * 10 AS decade, count(*)::int AS count + FROM album + WHERE ${searchFilter(input, 'decades')} AND album.release_year IS NOT NULL + GROUP BY 1 + ORDER BY 1 + `), + ]); + + const rows = page.rows.slice(0, limit); + const edges = rows.map(({ sortValue, ...node }) => ({ + cursor: encodeCursor({ sort: input.sort, value: sortValue, id: node.id }), + node, + })); + + return { + edges, + pageInfo: { + hasNextPage: page.rows.length > limit, + endCursor: edges.at(-1)?.cursor ?? null, + }, + totalCount: total.rows[0]?.count ?? 0, + genreFacets: genreFacets.rows.map(({ count, ...g }) => ({ genre: g, count })), + decadeFacets: decadeFacets.rows, + }; +} + +// Fields not listed here (tracks, ratings, reviews, crate) arrive with their +// own issues. +export const resolvers = { + Query: { + searchAlbums: (_: unknown, args: SearchArgs, ctx: Context) => + searchAlbums(ctx.db, args), + }, + Album: { + artists: (album: AlbumRow, _: unknown, ctx: Context) => + ctx.loaders.artistsByAlbum.load(album.id), + genres: (album: AlbumRow, _: unknown, ctx: Context) => + ctx.loaders.genresByAlbum.load(album.id), + }, +}; diff --git a/server/src/search/search.test.ts b/server/src/search/search.test.ts new file mode 100644 index 0000000..23d6e5a --- /dev/null +++ b/server/src/search/search.test.ts @@ -0,0 +1,111 @@ +import type { SQL } from 'drizzle-orm'; +import { PgDialect } from 'drizzle-orm/pg-core'; +import { describe, expect, it } from 'vitest'; +import { + afterCursor, + decodeCursor, + encodeCursor, + pageSize, + searchFilter, + sortKey, + type AlbumSearchInput, +} from './search.js'; + +// Renders a drizzle SQL fragment to the text and parameters Postgres receives. +const dialect = new PgDialect(); +const render = (fragment: SQL) => dialect.sqlToQuery(fragment); + +const input = (over: Partial = {}): AlbumSearchInput => ({ + query: '', + sort: 'TITLE_ASC', + ...over, +}); + +describe('cursors', () => { + it('round-trips value and id', () => { + const cursor = { sort: 'RELEASE_YEAR_DESC', value: 1997, id: 42 } as const; + expect(decodeCursor(encodeCursor(cursor), 'RELEASE_YEAR_DESC')).toEqual(cursor); + }); + + it('round-trips a NULL sort value', () => { + const cursor = { sort: 'RELEASE_YEAR_DESC', value: null, id: 7 } as const; + expect(decodeCursor(encodeCursor(cursor), 'RELEASE_YEAR_DESC')).toEqual(cursor); + }); + + it('rejects garbage and cursors from another sort', () => { + expect(() => decodeCursor('not a cursor', 'TITLE_ASC')).toThrow('Invalid cursor'); + const cursor = encodeCursor({ sort: 'TITLE_ASC', value: 'abbey road', id: 1 }); + expect(() => decodeCursor(cursor, 'RELEASE_YEAR_DESC')).toThrow('different sort'); + }); +}); + +describe('afterCursor', () => { + it('continues after the cursor row for an ascending sort', () => { + const key = sortKey(input({ sort: 'TITLE_ASC' })); + const { sql, params } = render( + afterCursor(key, { sort: 'TITLE_ASC', value: 'blue', id: 5 }), + ); + expect(sql).toBe( + '(lower(album.title) > $1 OR (lower(album.title) = $2 AND album.id > $3))', + ); + expect(params).toEqual(['blue', 'blue', 5]); + }); + + it('includes the NULL years that sort after a non-NULL cursor', () => { + const key = sortKey(input({ sort: 'RELEASE_YEAR_DESC' })); + const { sql } = render( + afterCursor(key, { sort: 'RELEASE_YEAR_DESC', value: 1990, id: 3 }), + ); + expect(sql).toContain('album.release_year < $1'); + expect(sql).toContain('album.release_year IS NULL'); + }); + + it('only pages through NULL years once the cursor is on one', () => { + const key = sortKey(input({ sort: 'RELEASE_YEAR_DESC' })); + const { sql, params } = render( + afterCursor(key, { sort: 'RELEASE_YEAR_DESC', value: null, id: 3 }), + ); + expect(sql).toBe('(album.release_year IS NULL AND album.id > $1)'); + expect(params).toEqual([3]); + }); +}); + +describe('searchFilter', () => { + it('matches the term with websearch_to_tsquery and passes it as a parameter', () => { + const { sql, params } = render(searchFilter(input({ query: ' Blue ' }))); + expect(sql).toBe(`album.search_vector @@ websearch_to_tsquery('simple', $1)`); + expect(params).toEqual(['Blue']); + }); + + it('has no condition for an empty search', () => { + expect(render(searchFilter(input())).sql).toBe('TRUE'); + }); + + it('drops the genre filter for genre facets but keeps decades', () => { + const filters = input({ genreIds: ['1', '2'], decades: [1990] }); + expect(render(searchFilter(filters)).sql).toContain('album_genre'); + const { sql } = render(searchFilter(filters, 'genres')); + expect(sql).not.toContain('album_genre'); + expect(sql).toContain('release_year'); + }); + + it('rejects a non-numeric genre id', () => { + expect(() => searchFilter(input({ genreIds: ['rock'] }))).toThrow('Invalid genre id'); + }); +}); + +describe('sortKey and pageSize', () => { + it('ranks by relevance only when there is a search term', () => { + expect( + render(sortKey(input({ sort: 'RELEVANCE', query: 'blue' })).expr).sql, + ).toContain('ts_rank'); + expect(render(sortKey(input({ sort: 'RELEVANCE' })).expr).sql).toBe( + 'lower(album.title)', + ); + }); + + it('clamps the page size', () => { + expect(pageSize(0)).toBe(1); + expect(pageSize(1000)).toBe(50); + }); +}); diff --git a/server/src/search/search.ts b/server/src/search/search.ts new file mode 100644 index 0000000..2d2d0f1 --- /dev/null +++ b/server/src/search/search.ts @@ -0,0 +1,154 @@ +import { sql, type SQL } from 'drizzle-orm'; +import { GraphQLError } from 'graphql'; + +/** + * Pure building blocks for searchAlbums: the WHERE clause, the sort order and + * the keyset cursor. Nothing here touches the database, so it is unit-tested + * by inspecting the SQL it produces (search.test.ts). + */ + +export type AlbumSort = + 'RELEVANCE' | 'TITLE_ASC' | 'RELEASE_YEAR_DESC' | 'AVERAGE_RATING_DESC'; + +export type AlbumSearchInput = { + query: string; + genreIds?: readonly string[] | null; + decades?: readonly number[] | null; + sort: AlbumSort; +}; + +const MAX_PAGE_SIZE = 50; + +export function pageSize(first: number): number { + return Math.min(Math.max(first, 1), MAX_PAGE_SIZE); +} + +// websearch_to_tsquery never throws on odd user input (quotes, dashes, "or"), +// and 'simple' lowercases words the same way the stored search_vector does. +function tsQuery(query: string): SQL { + return sql`websearch_to_tsquery('simple', ${query})`; +} + +function parseGenreId(id: string): number { + const n = Number(id); + if (!Number.isInteger(n)) throw new GraphQLError(`Invalid genre id: ${id}`); + return n; +} + +/** + * WHERE clause for the search. `omit` drops one filter, which is how facet + * counts are computed: the genre counts ignore the selected genres (but keep + * the search term and decades), so picking one genre does not hide the others. + */ +export function searchFilter(input: AlbumSearchInput, omit?: 'genres' | 'decades'): SQL { + const conditions: SQL[] = []; + + const query = input.query.trim(); + if (query) { + conditions.push(sql`album.search_vector @@ ${tsQuery(query)}`); + } + + if (omit !== 'genres' && input.genreIds?.length) { + const ids = input.genreIds.map((id) => sql`${parseGenreId(id)}`); + conditions.push(sql`EXISTS ( + SELECT 1 FROM album_genre ag + WHERE ag.album_id = album.id AND ag.genre_id IN (${sql.join(ids, sql`, `)}) + )`); + } + + if (omit !== 'decades' && input.decades?.length) { + const decades = input.decades.map((d) => sql`${d}`); + conditions.push( + sql`(album.release_year / 10) * 10 IN (${sql.join(decades, sql`, `)})`, + ); + } + + return conditions.length ? sql.join(conditions, sql` AND `) : sql`TRUE`; +} + +/** + * What the results are ordered by. Every sort ends with album.id as a + * tiebreaker so the order is total, which keyset paging needs. + */ +export type SortKey = { + expr: SQL; + direction: 'ASC' | 'DESC'; + // Nullable keys (release year) put NULLs last and need extra cursor logic. + nullable: boolean; +}; + +export function sortKey(input: AlbumSearchInput): SortKey { + const query = input.query.trim(); + switch (input.sort) { + case 'RELEVANCE': + // Without a search term every rank is equal, so fall back to title. + if (query) { + return { + expr: sql`ts_rank(album.search_vector, ${tsQuery(query)})`, + direction: 'DESC', + nullable: false, + }; + } + return { expr: sql`lower(album.title)`, direction: 'ASC', nullable: false }; + case 'TITLE_ASC': + return { expr: sql`lower(album.title)`, direction: 'ASC', nullable: false }; + case 'RELEASE_YEAR_DESC': + return { expr: sql`album.release_year`, direction: 'DESC', nullable: true }; + case 'AVERAGE_RATING_DESC': + throw new GraphQLError( + 'Sorting by rating is not available until reviews are stored.', + ); + } +} + +export function orderBy(key: SortKey): SQL { + return sql`${key.expr} ${sql.raw(key.direction)} NULLS LAST, album.id ASC`; +} + +/** + * A cursor points at the last row of a page: its sort value and its id. The + * next page asks for rows strictly after that point instead of using OFFSET, + * so page 5000 costs the same as page 1. + */ +export type Cursor = { sort: AlbumSort; value: string | number | null; id: number }; + +export function encodeCursor(cursor: Cursor): string { + return Buffer.from(JSON.stringify([cursor.sort, cursor.value, cursor.id])).toString( + 'base64url', + ); +} + +export function decodeCursor(encoded: string, sort: AlbumSort): Cursor { + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')); + } catch { + throw new GraphQLError('Invalid cursor'); + } + if (!Array.isArray(parsed) || parsed.length !== 3) + throw new GraphQLError('Invalid cursor'); + const [cursorSort, value, id] = parsed as unknown[]; + if (cursorSort !== sort) throw new GraphQLError('Cursor belongs to a different sort'); + if (!Number.isInteger(id)) throw new GraphQLError('Invalid cursor'); + if (value !== null && typeof value !== 'string' && typeof value !== 'number') { + throw new GraphQLError('Invalid cursor'); + } + return { sort, value, id: id as number }; +} + +/** "Comes after the cursor" in the order given by orderBy(key). */ +export function afterCursor(key: SortKey, cursor: Cursor): SQL { + const { value, id } = cursor; + // The cursor row had no value, so only the NULL rows after it by id remain. + if (value === null) { + return sql`(${key.expr} IS NULL AND album.id > ${id})`; + } + const beyond = key.direction === 'ASC' ? sql`>` : sql`<`; + const parts = [ + sql`${key.expr} ${beyond} ${value}`, + sql`(${key.expr} = ${value} AND album.id > ${id})`, + ]; + // NULLs are sorted last, so they all come after any non-NULL cursor. + if (key.nullable) parts.push(sql`${key.expr} IS NULL`); + return sql`(${sql.join(parts, sql` OR `)})`; +} diff --git a/server/tsconfig.build.json b/server/tsconfig.build.json new file mode 100644 index 0000000..0a11719 --- /dev/null +++ b/server/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts"] +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..4abbc54 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*"] +} From 7a97fc82e71b74dff58c8bdfd6c2fe0cc311794a Mon Sep 17 00:00:00 2001 From: Even Date: Tue, 13 Oct 2026 16:43:39 +0200 Subject: [PATCH 2/2] fix: #6 address review feedback on searchAlbums - Validate cursor values against the sort, so a mismatched cursor gets "Invalid cursor" instead of a Postgres error or wrong page. - Album.reviewCount returns 0 until reviews are stored. - Genre filters combine with AND, as agreed in the group. Genre facet counts now include the selected genres, so "Pop (3)" with Rock ticked matches what ticking Pop returns. Decades stay OR. - Smoke suite expectations and AI usage log updated. --- docs/AI-USAGE.md | 2 ++ docs/VM-setup.md | 6 ++-- server/src/resolvers.ts | 4 ++- server/src/search/search.test.ts | 35 +++++++++++++++++---- server/src/search/search.ts | 53 ++++++++++++++++++++++++-------- 5 files changed, 77 insertions(+), 23 deletions(-) diff --git a/docs/AI-USAGE.md b/docs/AI-USAGE.md index 321d7e7..d919b9e 100644 --- a/docs/AI-USAGE.md +++ b/docs/AI-USAGE.md @@ -35,6 +35,8 @@ the part that matters most. | 2026-10-01 | erikhfj | opencode | CI deploy fix for #27 after rachelks reported it: `grep -q` SIGPIPE silently skipped a live service, `sudo` had no TTY, and `sleep 2` raced Node binding the port. All three verified against the live unit | | 2026-10-01 | erikhfj | opencode | Simplified this log to a table at the group's request | | 2026-10-01 | robertky | Codex (ChatGPT) | Assisted with formulating good reviews of the architecture and UI-specification PRs. The group decided to include decade filtering; Rachel implemented the corresponding schema and documentation update in #38. | +| 2026-10-10 | evenkkl | Claude | Assisted with implementing the GraphQL Yoga server with `searchAlbums` for #6, and formulated an easy to understand PR description. Guided the reset of the prototype tables in the VM database (backup taken first). | +| 2026-10-13 | evenkkl | Claude | Fixed the review feedback on #6: cursor values validated against the sort, `reviewCount` returns 0 until reviews exist. The group decided genre filters combine with AND; Claude made the filter and the genre counts match that. | ## Notes diff --git a/docs/VM-setup.md b/docs/VM-setup.md index 5bf4237..59d184b 100644 --- a/docs/VM-setup.md +++ b/docs/VM-setup.md @@ -47,9 +47,9 @@ gql '{"query":"{ a: searchAlbums(input:{query:\"blue\",sort:RELEVANCE},first:5){ gql '{"query":"{ searchAlbums(input:{query:\"coltrane\",sort:TITLE_ASC},first:5){ totalCount edges { node { title releaseYear artists { name } genres { name } } } } }"}' ``` -4. Facets ignore their own filter. With Rock (id 4) selected, expect - `totalCount` 7, `genreFacets` still listing all seven genres, and - `decadeFacets` counting only Rock albums. +4. Facets. With Rock (id 4) selected, expect `totalCount` 7. Genres combine + with AND, so `genreFacets` counts albums that are Rock and that genre: + Rock 7, Pop 3, Electronic 2. `decadeFacets` counts only Rock albums. ```bash gql '{"query":"{ searchAlbums(input:{query:\"\",genreIds:[\"4\"],sort:TITLE_ASC},first:5){ totalCount genreFacets { genre { name } count } decadeFacets { decade count } } }"}' diff --git a/server/src/resolvers.ts b/server/src/resolvers.ts index 80b8f35..b5f4d10 100644 --- a/server/src/resolvers.ts +++ b/server/src/resolvers.ts @@ -47,7 +47,7 @@ async function searchAlbums(db: Db, { input, first, after }: SearchArgs) { FROM album JOIN album_genre ON album_genre.album_id = album.id JOIN genre ON genre.id = album_genre.genre_id - WHERE ${searchFilter(input, 'genres')} + WHERE ${filter} GROUP BY genre.id, genre.name ORDER BY count DESC, genre.name `), @@ -90,5 +90,7 @@ export const resolvers = { ctx.loaders.artistsByAlbum.load(album.id), genres: (album: AlbumRow, _: unknown, ctx: Context) => ctx.loaders.genresByAlbum.load(album.id), + // Required by the schema. Real counts come with the reviews issue. + reviewCount: () => 0, }, }; diff --git a/server/src/search/search.test.ts b/server/src/search/search.test.ts index 23d6e5a..1d7d2ca 100644 --- a/server/src/search/search.test.ts +++ b/server/src/search/search.test.ts @@ -70,6 +70,22 @@ describe('afterCursor', () => { }); }); +describe('cursor value validation', () => { + it('rejects a value of the wrong type for the sort', () => { + const yearKey = sortKey(input({ sort: 'RELEASE_YEAR_DESC' })); + expect(() => + afterCursor(yearKey, { sort: 'RELEASE_YEAR_DESC', value: 'abbey road', id: 1 }), + ).toThrow('Invalid cursor'); + }); + + it('rejects NULL for a sort that never has NULLs', () => { + const titleKey = sortKey(input({ sort: 'TITLE_ASC' })); + expect(() => + afterCursor(titleKey, { sort: 'TITLE_ASC', value: null, id: 1 }), + ).toThrow('Invalid cursor'); + }); +}); + describe('searchFilter', () => { it('matches the term with websearch_to_tsquery and passes it as a parameter', () => { const { sql, params } = render(searchFilter(input({ query: ' Blue ' }))); @@ -81,12 +97,19 @@ describe('searchFilter', () => { expect(render(searchFilter(input())).sql).toBe('TRUE'); }); - it('drops the genre filter for genre facets but keeps decades', () => { - const filters = input({ genreIds: ['1', '2'], decades: [1990] }); - expect(render(searchFilter(filters)).sql).toContain('album_genre'); - const { sql } = render(searchFilter(filters, 'genres')); - expect(sql).not.toContain('album_genre'); - expect(sql).toContain('release_year'); + it('drops only the decade filter for decade facets', () => { + const filters = input({ genreIds: ['1'], decades: [1990] }); + expect(render(searchFilter(filters)).sql).toContain('release_year'); + const { sql } = render(searchFilter(filters, 'decades')); + expect(sql).not.toContain('release_year'); + expect(sql).toContain('album_genre'); + }); + + it('requires every selected genre (AND)', () => { + const { sql, params } = render(searchFilter(input({ genreIds: ['3', '4'] }))); + expect(sql.match(/EXISTS/g)).toHaveLength(2); + expect(sql).toContain(' AND EXISTS'); + expect(params).toEqual([3, 4]); }); it('rejects a non-numeric genre id', () => { diff --git a/server/src/search/search.ts b/server/src/search/search.ts index 2d2d0f1..6f05924 100644 --- a/server/src/search/search.ts +++ b/server/src/search/search.ts @@ -36,11 +36,12 @@ function parseGenreId(id: string): number { } /** - * WHERE clause for the search. `omit` drops one filter, which is how facet - * counts are computed: the genre counts ignore the selected genres (but keep - * the search term and decades), so picking one genre does not hide the others. + * WHERE clause for the search. Passing omit = 'decades' drops the decade filter, + * which is how decade counts are computed: picking the 1990s still shows how + * many albums each other decade has. Genre counts use the full filter, because + * genres combine with AND: with Rock picked, "Pop" counts albums that are both. */ -export function searchFilter(input: AlbumSearchInput, omit?: 'genres' | 'decades'): SQL { +export function searchFilter(input: AlbumSearchInput, omit?: 'decades'): SQL { const conditions: SQL[] = []; const query = input.query.trim(); @@ -48,14 +49,18 @@ export function searchFilter(input: AlbumSearchInput, omit?: 'genres' | 'decades conditions.push(sql`album.search_vector @@ ${tsQuery(query)}`); } - if (omit !== 'genres' && input.genreIds?.length) { - const ids = input.genreIds.map((id) => sql`${parseGenreId(id)}`); - conditions.push(sql`EXISTS ( - SELECT 1 FROM album_genre ag - WHERE ag.album_id = album.id AND ag.genre_id IN (${sql.join(ids, sql`, `)}) - )`); + // Genres combine with AND: picking Rock and Pop keeps only albums tagged + // with both. One EXISTS per selected genre. + if (input.genreIds?.length) { + for (const id of input.genreIds) { + conditions.push(sql`EXISTS ( + SELECT 1 FROM album_genre ag + WHERE ag.album_id = album.id AND ag.genre_id = ${parseGenreId(id)} + )`); + } } + // Decades combine with OR, since an album has only one release year. if (omit !== 'decades' && input.decades?.length) { const decades = input.decades.map((d) => sql`${d}`); conditions.push( @@ -75,6 +80,8 @@ export type SortKey = { direction: 'ASC' | 'DESC'; // Nullable keys (release year) put NULLs last and need extra cursor logic. nullable: boolean; + // What a cursor's value must be for this sort, checked before it reaches SQL. + valueType: 'string' | 'number'; }; export function sortKey(input: AlbumSearchInput): SortKey { @@ -87,13 +94,29 @@ export function sortKey(input: AlbumSearchInput): SortKey { expr: sql`ts_rank(album.search_vector, ${tsQuery(query)})`, direction: 'DESC', nullable: false, + valueType: 'number', }; } - return { expr: sql`lower(album.title)`, direction: 'ASC', nullable: false }; + return { + expr: sql`lower(album.title)`, + direction: 'ASC', + nullable: false, + valueType: 'string', + }; case 'TITLE_ASC': - return { expr: sql`lower(album.title)`, direction: 'ASC', nullable: false }; + return { + expr: sql`lower(album.title)`, + direction: 'ASC', + nullable: false, + valueType: 'string', + }; case 'RELEASE_YEAR_DESC': - return { expr: sql`album.release_year`, direction: 'DESC', nullable: true }; + return { + expr: sql`album.release_year`, + direction: 'DESC', + nullable: true, + valueType: 'number', + }; case 'AVERAGE_RATING_DESC': throw new GraphQLError( 'Sorting by rating is not available until reviews are stored.', @@ -139,6 +162,10 @@ export function decodeCursor(encoded: string, sort: AlbumSort): Cursor { /** "Comes after the cursor" in the order given by orderBy(key). */ export function afterCursor(key: SortKey, cursor: Cursor): SQL { const { value, id } = cursor; + // A value of the wrong type (a title under a year sort, or NULL where the + // sort never has NULLs) would otherwise fail in Postgres or page wrongly. + const valid = value === null ? key.nullable : typeof value === key.valueType; + if (!valid) throw new GraphQLError('Invalid cursor'); // The cursor row had no value, so only the NULL rows after it by id remain. if (value === null) { return sql`(${key.expr} IS NULL AND album.id > ${id})`;