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 new file mode 100644 index 0000000..59d184b --- /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. 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 } } }"}' +``` + +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..b5f4d10 --- /dev/null +++ b/server/src/resolvers.ts @@ -0,0 +1,96 @@ +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 ${filter} + 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), + // 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 new file mode 100644 index 0000000..1d7d2ca --- /dev/null +++ b/server/src/search/search.test.ts @@ -0,0 +1,134 @@ +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('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 ' }))); + 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 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', () => { + 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..6f05924 --- /dev/null +++ b/server/src/search/search.ts @@ -0,0 +1,181 @@ +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. 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?: 'decades'): SQL { + const conditions: SQL[] = []; + + const query = input.query.trim(); + if (query) { + conditions.push(sql`album.search_vector @@ ${tsQuery(query)}`); + } + + // 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( + 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; + // What a cursor's value must be for this sort, checked before it reaches SQL. + valueType: 'string' | 'number'; +}; + +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, + valueType: 'number', + }; + } + 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, + valueType: 'string', + }; + case 'RELEASE_YEAR_DESC': + 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.', + ); + } +} + +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; + // 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})`; + } + 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/**/*"] +}