diff --git a/backend/package.json b/backend/package.json index dc8d7ab3..f39ec90d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,10 +23,11 @@ "@apollo/server": "^5.5.1", "graphql": "^17.0.2", "graphql-tag": "^2.12.7", - "mongoose": "^9.10.2" + "pg": "^8.23.0" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/pg": "^8.23.1", "eslint": "^10.11.0", "globals": "^17.12.0", "jiti": "^2.7.0", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index aa98bd74..ae992b23 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -175,13 +175,16 @@ importers: graphql-tag: specifier: ^2.12.7 version: 2.12.7(graphql@17.0.2) - mongoose: - specifier: ^9.10.2 - version: 9.10.2 + pg: + specifier: ^8.23.0 + version: 8.23.0 devDependencies: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.11.0(jiti@2.7.0)) + '@types/pg': + specifier: ^8.23.1 + version: 8.23.1 eslint: specifier: ^10.11.0 version: 10.11.0(jiti@2.7.0) @@ -196,7 +199,7 @@ importers: version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: ^5.0.2 - version: 5.0.2(vite@8.3.1(jiti@2.7.0)) + version: 5.0.2(@types/node@26.6.3)(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)) packages: @@ -390,9 +393,6 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@mongodb-js/saslprep@1.5.4': - resolution: {integrity: sha512-05UC0jQsjKAOuXQ0H9Ud9vUTJpZIg+n/FinpR30tI5I8pY2inTfPOZ5OF/cg3Ce/N9MoD1xhRCeOsJtuTbFYlw==} - '@oxc-project/types@0.151.0': resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} @@ -525,9 +525,6 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -546,11 +543,11 @@ packages: '@types/long@4.0.2': resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} - '@types/webidl-conversions@7.0.3': - resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + '@types/node@26.6.3': + resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==} - '@types/whatwg-url@13.0.0': - resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==} + '@types/pg@8.23.1': + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} '@typescript-eslint/eslint-plugin@8.70.1': resolution: {integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==} @@ -665,10 +662,6 @@ packages: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} - bson@7.3.3: - resolution: {integrity: sha512-oz3LwE3qGEhTCSyhzQRLY3Bj1NLOsjFMf6tcmY5J8M+3R7EVcmoBfSHWvjUK3yXCLDUsGpIuB3aQL4nDZ1EV/g==} - engines: {node: '>=20.19.0'} - bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -975,10 +968,6 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - kareem@3.4.0: - resolution: {integrity: sha512-JAKhnR4S027v/8949QXNInRskgETCg9rFUMi1VjhGbjX+XltCAAEFcXykkSocNkom1kc66EMJfjWvhj87BDQKg==} - engines: {node: '>=18.0.0'} - keyv@5.6.0: resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} @@ -1089,9 +1078,6 @@ packages: resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} - memory-pager@1.5.0: - resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} - mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} @@ -1104,49 +1090,6 @@ packages: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} - mongodb-connection-string-url@7.0.2: - resolution: {integrity: sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==} - engines: {node: '>=20.19.0'} - - mongodb@7.6.0: - resolution: {integrity: sha512-WbZ6OCjYw2c53LOjfkQa+reXr7kIiOVpXXglnASFuiMtif0BvsMwHe3ClJHLm1/r7wJFwaasfFtD6iYIktB01g==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@aws-sdk/credential-providers': ^3.806.0 - '@mongodb-js/zstd': ^7.0.0 - gcp-metadata: ^7.0.1 - kerberos: ^7.0.0 - mongodb-client-encryption: ^7.2.0 - snappy: ^7.3.2 - socks: ^2.8.6 - peerDependenciesMeta: - '@aws-sdk/credential-providers': - optional: true - '@mongodb-js/zstd': - optional: true - gcp-metadata: - optional: true - kerberos: - optional: true - mongodb-client-encryption: - optional: true - snappy: - optional: true - socks: - optional: true - - mongoose@9.10.2: - resolution: {integrity: sha512-oFcFL3dsX5tDzMwBD+GGoDqKIj6RNG3bu3oqlvcUIgPulHP14/6N7c07j3s0XgbrlT7U/lbiXOD89hmPtEXnfQ==} - engines: {node: '>=20.19.0'} - - mpath@0.9.0: - resolution: {integrity: sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==} - engines: {node: '>=4.0.0'} - - mquery@6.0.0: - resolution: {integrity: sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==} - engines: {node: '>=20.19.0'} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1202,6 +1145,40 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.0: + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.0: + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1217,6 +1194,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'} @@ -1293,15 +1286,13 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - sift@17.1.3: - resolution: {integrity: sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - sparse-bitfield@3.0.3: - resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} @@ -1330,10 +1321,6 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - tr46@5.1.1: - resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} - engines: {node: '>=18'} - ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1367,6 +1354,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -1462,18 +1452,10 @@ packages: jsdom: optional: true - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} - whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} - whatwg-url@14.2.0: - resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} - engines: {node: '>=18'} - which-typed-array@1.1.24: resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==} engines: {node: '>= 0.4'} @@ -1492,6 +1474,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1708,10 +1694,6 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@mongodb-js/saslprep@1.5.4': - dependencies: - sparse-bitfield: 3.0.3 - '@oxc-project/types@0.151.0': {} '@protobufjs/aspromise@1.1.2': {} @@ -1783,8 +1765,6 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@standard-schema/spec@1.1.0': {} - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1800,11 +1780,15 @@ snapshots: '@types/long@4.0.2': {} - '@types/webidl-conversions@7.0.3': {} + '@types/node@26.6.3': + dependencies: + undici-types: 8.9.0 - '@types/whatwg-url@13.0.0': + '@types/pg@8.23.1': dependencies: - '@types/webidl-conversions': 7.0.3 + '@types/node': 26.6.3 + pg-protocol: 1.16.0 + pg-types: 2.2.0 '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: @@ -1897,14 +1881,14 @@ snapshots: '@typescript-eslint/types': 8.70.1 eslint-visitor-keys: 5.0.1 - '@vitest/mocker@5.0.2(vite@8.3.1(jiti@2.7.0))': + '@vitest/mocker@5.0.2(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.2 estree-walker: 3.0.3 magic-string: 1.4.2 optionalDependencies: - vite: 8.3.1(jiti@2.7.0) + vite: 8.3.1(@types/node@26.6.3)(jiti@2.7.0) '@vitest/spy@5.0.2': {} @@ -1955,8 +1939,6 @@ snapshots: dependencies: balanced-match: 4.0.4 - bson@7.3.3: {} - bytes@3.1.2: {} cacheable@2.5.0: @@ -2261,8 +2243,6 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} - kareem@3.4.0: {} - keyv@5.6.0: dependencies: '@keyv/serialize': 1.1.1 @@ -2341,8 +2321,6 @@ snapshots: media-typer@1.1.1: {} - memory-pager@1.5.0: {} - mime-db@1.54.0: {} mime-types@3.0.2: @@ -2353,39 +2331,6 @@ snapshots: dependencies: brace-expansion: 5.0.12 - mongodb-connection-string-url@7.0.2: - dependencies: - '@types/whatwg-url': 13.0.0 - whatwg-url: 14.2.0 - - mongodb@7.6.0: - dependencies: - '@mongodb-js/saslprep': 1.5.4 - bson: 7.3.3 - mongodb-connection-string-url: 7.0.2 - - mongoose@9.10.2: - dependencies: - '@standard-schema/spec': 1.1.0 - kareem: 3.4.0 - mongodb: 7.6.0 - mpath: 0.9.0 - mquery: 6.0.0 - ms: 2.1.3 - sift: 17.1.3 - transitivePeerDependencies: - - '@aws-sdk/credential-providers' - - '@mongodb-js/zstd' - - gcp-metadata - - kerberos - - mongodb-client-encryption - - snappy - - socks - - mpath@0.9.0: {} - - mquery@6.0.0: {} - ms@2.1.3: {} nanoid@3.3.19: {} @@ -2429,6 +2374,41 @@ snapshots: path-key@3.1.1: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.0): + dependencies: + pg: 8.23.0 + + pg-protocol@1.16.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.23.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.23.0) + pg-protocol: 1.16.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@4.0.7: {} @@ -2441,6 +2421,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: {} punycode@2.3.1: {} @@ -2541,13 +2531,9 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - sift@17.1.3: {} - source-map-js@1.2.1: {} - sparse-bitfield@3.0.3: - dependencies: - memory-pager: 1.5.0 + split2@4.2.0: {} statuses@2.0.2: {} @@ -2570,10 +2556,6 @@ snapshots: toidentifier@1.0.1: {} - tr46@5.1.1: - dependencies: - punycode: 2.3.1 - ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -2609,6 +2591,8 @@ snapshots: typescript@6.0.3: {} + undici-types@8.9.0: {} + unpipe@1.0.0: {} uri-js@4.4.1: @@ -2617,7 +2601,7 @@ snapshots: vary@1.1.2: {} - vite@8.3.1(jiti@2.7.0): + vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -2625,13 +2609,14 @@ snapshots: rolldown: 1.2.11 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 26.6.3 fsevents: 2.3.3 jiti: 2.7.0 - vitest@5.0.2(vite@8.3.1(jiti@2.7.0)): + vitest@5.0.2(@types/node@26.6.3)(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.2(vite@8.3.1(jiti@2.7.0)) + '@vitest/mocker': 5.0.2(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -2642,20 +2627,15 @@ snapshots: tinybench: 6.2.0 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.1(jiti@2.7.0) + vite: 8.3.1(@types/node@26.6.3)(jiti@2.7.0) why-is-node-running: 3.2.2 + optionalDependencies: + '@types/node': 26.6.3 transitivePeerDependencies: - msw - webidl-conversions@7.0.0: {} - whatwg-mimetype@4.0.0: {} - whatwg-url@14.2.0: - dependencies: - tr46: 5.1.1 - webidl-conversions: 7.0.0 - which-typed-array@1.1.24: dependencies: available-typed-arrays: 1.0.7 @@ -2674,4 +2654,6 @@ snapshots: word-wrap@1.2.5: {} + xtend@4.0.2: {} + yocto-queue@0.1.0: {} diff --git a/db/.gitignore b/db/.gitignore new file mode 100644 index 00000000..4bed5da9 --- /dev/null +++ b/db/.gitignore @@ -0,0 +1 @@ +*.parquet diff --git a/db/indexes.sql b/db/indexes.sql new file mode 100644 index 00000000..52263486 --- /dev/null +++ b/db/indexes.sql @@ -0,0 +1,19 @@ + +CREATE INDEX games_name_trgm ON games USING gin (name gin_trgm_ops); + +CREATE INDEX games_added_id ON games (added DESC, id DESC); +CREATE INDEX games_rating_id ON games (rating DESC NULLS LAST, id DESC); +CREATE INDEX games_released_id ON games (released DESC NULLS LAST, id DESC); +CREATE INDEX games_meta_id ON games (metacritic DESC NULLS LAST, id DESC); +CREATE INDEX games_name_id ON games (name, id); + +CREATE INDEX game_genres_rev ON game_genres (genre_id, game_id); +CREATE INDEX game_platforms_rev ON game_platforms (platform_id, game_id); +CREATE INDEX game_stores_rev ON game_stores (store_id, game_id); +CREATE INDEX game_developers_rev ON game_developers (developer_id, game_id); +CREATE INDEX game_publishers_rev ON game_publishers (publisher_id, game_id); +CREATE INDEX game_tags_rev ON game_tags (tag_id, game_id); + +CREATE INDEX reviews_game_created ON reviews (game_id, created_at DESC); + + diff --git a/db/load.py b/db/load.py new file mode 100644 index 00000000..4ee04537 --- /dev/null +++ b/db/load.py @@ -0,0 +1,134 @@ +import os +import sys +import tempfile +from pathlib import Path + +import duckdb +import psycopg + +HERE = Path(__file__).parent +PARQUET = os.environ.get("PARQUET_PATH", str(Path.home() / "data" / "videogames_data.parquet")) +DATABASE_URL = os.environ["DATABASE_URL"] +FORCE = "--force" in sys.argv + +# (parquet column, lookup table, junction table, junction fk column) +RELATIONS = [ + ("genres", "genres", "game_genres", "genre_id"), + ("platforms", "platforms", "game_platforms", "platform_id"), + ("stores", "stores", "game_stores", "store_id"), + ("developers", "developers", "game_developers", "developer_id"), + ("publishers", "publishers", "game_publishers", "publisher_id"), + ("tags", "tags", "game_tags", "tag_id"), +] + +REQUIRED_COLUMNS = [ + "id", "slug", "name", "released", "background_image", "rating", "ratings_count", + "added", "metacritic", "playtime", "esrb_rating", "description_raw", "website", + "dominant_color", "genres", "platforms", "stores", "developers", "publishers", "tags", +] + +GAME_COLUMNS = ( + "id, slug, name, released, background_image, rating, ratings_count, added, " + "metacritic, playtime, esrb_rating, description, website, dominant_color" +) + + +def export_csvs(out: Path) -> None: + con = duckdb.connect() + + found = {r[0] for r in con.execute(f"DESCRIBE SELECT * FROM read_parquet('{PARQUET}')").fetchall()} + missing = [c for c in REQUIRED_COLUMNS if c not in found] + if missing: + sys.exit(f"Parquet file is missing expected columns: {missing}\nAvailable: {sorted(found)}") + + # The dataset stores missing values as the text 'None'; convert to real NULLs. + con.execute( + f"""CREATE TEMP TABLE src AS + SELECT + id, + NULLIF(slug, 'None') AS slug, + name, + TRY_CAST(released AS DATE) AS released, + NULLIF(background_image, 'None') AS background_image, + NULLIF(rating, 0) AS rating, + COALESCE(TRY_CAST(ratings_count AS INTEGER), 0) AS ratings_count, + TRY_CAST(added AS INTEGER) AS added, + TRY_CAST(metacritic AS INTEGER) AS metacritic, + TRY_CAST(playtime AS INTEGER) AS playtime, + NULLIF(regexp_extract(esrb_rating, '"name":[ ]*"([^"]*)"', 1), '') AS esrb_rating, + NULLIF(description_raw, 'None') AS description, + NULLIF(website, 'None') AS website, + NULLIF(dominant_color, 'None') AS dominant_color, + genres, platforms, stores, developers, publishers, tags + FROM read_parquet('{PARQUET}') + WHERE added > 0 AND name IS NOT NULL""" + ) + + con.execute( + f"COPY (SELECT {GAME_COLUMNS} FROM src) TO '{out}/games.csv' (FORMAT csv, HEADER)" + ) + + for col, table, junction, fk in RELATIONS: + con.execute( + f"""CREATE OR REPLACE TEMP TABLE pairs AS + SELECT game_id, trim(item) AS name FROM ( + SELECT id AS game_id, unnest(string_split({col}, '|')) AS item + FROM src WHERE {col} IS NOT NULL + ) t""" + ) + con.execute( + """CREATE OR REPLACE TEMP TABLE lk AS + SELECT row_number() OVER (ORDER BY name) AS id, name + FROM (SELECT DISTINCT name FROM pairs WHERE name NOT IN ('', 'None'))""" + ) + con.execute(f"COPY (SELECT id, name FROM lk) TO '{out}/{table}.csv' (FORMAT csv, HEADER)") + con.execute( + f"""COPY ( + SELECT DISTINCT p.game_id, l.id AS {fk} + FROM pairs p JOIN lk l ON l.name = p.name + ) TO '{out}/{junction}.csv' (FORMAT csv, HEADER)""" + ) + + +def copy_csv(cur: psycopg.Cursor, table: str, columns: str, path: Path) -> None: + sql = f"COPY {table} ({columns}) FROM STDIN WITH (FORMAT csv, HEADER true)" + with open(path, "rb") as f, cur.copy(sql) as cp: + while chunk := f.read(1 << 20): + cp.write(chunk) + + +def main() -> None: + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) + print("Exporting CSVs from Parquet...") + export_csvs(out) + + with psycopg.connect(DATABASE_URL) as conn: + cur = conn.cursor() + cur.execute("SELECT to_regclass('public.games')") + if cur.fetchone()[0] is not None and not FORCE: + sys.exit("games table already exists. Rerun with --force to wipe and reload (deletes reviews too).") + + print("Creating tables...") + cur.execute((HERE / "schema.sql").read_text()) + + print("Loading data...") + copy_csv(cur, "games", GAME_COLUMNS, out / "games.csv") + for _, table, junction, fk in RELATIONS: + copy_csv(cur, table, "id, name", out / f"{table}.csv") + copy_csv(cur, junction, f"game_id, {fk}", out / f"{junction}.csv") + + print("Creating indexes...") + cur.execute((HERE / "indexes.sql").read_text()) + conn.commit() + + cur.execute("ANALYZE") + conn.commit() + + for table in ["games"] + [r[1] for r in RELATIONS] + [r[2] for r in RELATIONS]: + cur.execute(f"SELECT count(*) FROM {table}") + print(f"{table}: {cur.fetchone()[0]}") + + +if __name__ == "__main__": + main() diff --git a/db/load.py.bak b/db/load.py.bak new file mode 100644 index 00000000..3a0d5798 --- /dev/null +++ b/db/load.py.bak @@ -0,0 +1,118 @@ +import os +import sys +import tempfile +from pathlib import Path + +import duckdb +import psycopg + +HERE = Path(__file__).parent +PARQUET = os.environ.get("PARQUET_PATH", str(Path.home() / "data" / "videogames_data.parquet")) +DATABASE_URL = os.environ["DATABASE_URL"] +FORCE = "--force" in sys.argv + +# (parquet column, lookup table, junction table, junction fk column) +RELATIONS = [ + ("genres", "genres", "game_genres", "genre_id"), + ("platforms", "platforms", "game_platforms", "platform_id"), + ("stores", "stores", "game_stores", "store_id"), + ("developers", "developers", "game_developers", "developer_id"), + ("publishers", "publishers", "game_publishers", "publisher_id"), + ("tags", "tags", "game_tags", "tag_id"), +] + +GAME_COLUMNS = ( + "id, slug, name, released, background_image, rating, ratings_count, added, " + "metacritic, playtime, esrb_rating, description, website, dominant_color" +) + + +def export_csvs(out: Path) -> None: + con = duckdb.connect() + con.execute( + f"""CREATE VIEW src AS + SELECT * FROM read_parquet('{PARQUET}') + WHERE added > 0 AND name IS NOT NULL""" + ) + con.execute( + f"""COPY ( + SELECT + id, + slug, + name, + TRY_CAST(released AS DATE) AS released, + background_image, + NULLIF(rating, 0) AS rating, + COALESCE(TRY_CAST(ratings_count AS INTEGER), 0) AS ratings_count, + TRY_CAST(added AS INTEGER) AS added, + TRY_CAST(metacritic AS INTEGER) AS metacritic, + TRY_CAST(playtime AS INTEGER) AS playtime, + json_extract_string(esrb_rating, '$.name') AS esrb_rating, + description_raw AS description, + website, + dominant_color + FROM src + ) TO '{out}/games.csv' (FORMAT csv, HEADER)""" + ) + for col, table, junction, fk in RELATIONS: + con.execute( + f"""CREATE OR REPLACE TEMP TABLE pairs AS + SELECT id AS game_id, trim(unnest(string_split({col}, '|'))) AS name + FROM src WHERE {col} IS NOT NULL""" + ) + con.execute( + """CREATE OR REPLACE TEMP TABLE lk AS + SELECT row_number() OVER (ORDER BY name) AS id, name + FROM (SELECT DISTINCT name FROM pairs WHERE name <> '')""" + ) + con.execute(f"COPY (SELECT id, name FROM lk) TO '{out}/{table}.csv' (FORMAT csv, HEADER)") + con.execute( + f"""COPY ( + SELECT DISTINCT p.game_id, l.id AS {fk} + FROM pairs p JOIN lk l ON l.name = p.name + ) TO '{out}/{junction}.csv' (FORMAT csv, HEADER)""" + ) + + +def copy_csv(cur: psycopg.Cursor, table: str, columns: str, path: Path) -> None: + sql = f"COPY {table} ({columns}) FROM STDIN WITH (FORMAT csv, HEADER true)" + with open(path, "rb") as f, cur.copy(sql) as cp: + while chunk := f.read(1 << 20): + cp.write(chunk) + + +def main() -> None: + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) + print("Exporting CSVs from Parquet...") + export_csvs(out) + + with psycopg.connect(DATABASE_URL) as conn: + cur = conn.cursor() + cur.execute("SELECT to_regclass('public.games')") + if cur.fetchone()[0] is not None and not FORCE: + sys.exit("games table already exists. Rerun with --force to wipe and reload (deletes reviews too).") + + print("Creating tables...") + cur.execute((HERE / "schema.sql").read_text()) + + print("Loading data...") + copy_csv(cur, "games", GAME_COLUMNS, out / "games.csv") + for _, table, junction, fk in RELATIONS: + copy_csv(cur, table, "id, name", out / f"{table}.csv") + copy_csv(cur, junction, f"game_id, {fk}", out / f"{junction}.csv") + + print("Creating indexes...") + cur.execute((HERE / "indexes.sql").read_text()) + conn.commit() + + cur.execute("ANALYZE") + conn.commit() + + for table in ["games"] + [r[1] for r in RELATIONS] + [r[2] for r in RELATIONS]: + cur.execute(f"SELECT count(*) FROM {table}") + print(f"{table}: {cur.fetchone()[0]}") + + +if __name__ == "__main__": + main() diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 00000000..43affb0f --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,68 @@ +DROP TABLE IF EXISTS reviews CASCADE; +DROP TABLE IF EXISTS game_genres, game_platforms, game_stores, game_developers, game_publishers, game_tags CASCADE; +DROP TABLE IF EXISTS genres, platforms, stores, developers, publishers, tags CASCADE; +DROP TABLE IF EXISTS games CASCADE; + +CREATE TABLE games ( + id BIGINT PRIMARY KEY, + slug TEXT, + name TEXT NOT NULL, + released DATE, + background_image TEXT, + rating REAL, + ratings_count INTEGER NOT NULL DEFAULT 0, + added INTEGER NOT NULL DEFAULT 0, + metacritic INTEGER, + playtime INTEGER, + esrb_rating TEXT, + description TEXT, + website TEXT, + dominant_color TEXT +); + +CREATE TABLE genres (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE platforms (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE stores (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE developers (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE publishers (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE tags (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); + +CREATE TABLE game_genres ( + game_id BIGINT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + genre_id INTEGER NOT NULL REFERENCES genres(id) ON DELETE CASCADE, + PRIMARY KEY (game_id, genre_id) +); +CREATE TABLE game_platforms ( + game_id BIGINT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + platform_id INTEGER NOT NULL REFERENCES platforms(id) ON DELETE CASCADE, + PRIMARY KEY (game_id, platform_id) +); +CREATE TABLE game_stores ( + game_id BIGINT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, + PRIMARY KEY (game_id, store_id) +); +CREATE TABLE game_developers ( + game_id BIGINT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + developer_id INTEGER NOT NULL REFERENCES developers(id) ON DELETE CASCADE, + PRIMARY KEY (game_id, developer_id) +); +CREATE TABLE game_publishers ( + game_id BIGINT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + publisher_id INTEGER NOT NULL REFERENCES publishers(id) ON DELETE CASCADE, + PRIMARY KEY (game_id, publisher_id) +); +CREATE TABLE game_tags ( + game_id BIGINT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (game_id, tag_id) +); + +CREATE TABLE reviews ( + id BIGSERIAL PRIMARY KEY, + game_id BIGINT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + author TEXT NOT NULL CHECK (char_length(author) BETWEEN 1 AND 50), + rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), + body TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d789a7cf..6624e2ea 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig } from 'vite' // https://vite.dev/config/ export default defineConfig({ + base: '/project2/', plugins: [ react(), babel({ presets: [reactCompilerPreset()] })