diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1366b0f..c0747b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: - name: Typecheck run: pnpm typecheck - # No-op until the frontend has a test script. + # Database tests start Postgres with Testcontainers, so they need Docker. - name: Test run: pnpm run --if-present test diff --git a/web/AGENTS.md b/web/AGENTS.md index af3b7c8..767670f 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -7,3 +7,20 @@ This version has breaking changes — APIs, conventions, and file structure may This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + +## Data access + +Domain modules in `src/lib//` (starting with `reviews`) are the only way the app reads, validates, and writes that domain's data. + +- Import a domain only through its entry points: `@/lib/` on the server, or `@/lib//shared` from client components. Files behind them are internal. +- Name functions by what they do: `load*` reads the database, `fetch*` makes network calls, and `assert*` throws (or redirects) when an invariant doesn't hold. +- Let callers decide what to see through filters, such as `labelled: "include" | "exclude" | "only"`, rather than returning flags for callers to filter on. +- Report expected failures as typed reasons (`{ ok: false, reason }`) and map them to messages in the app, next to the UI that shows them. +- Database functions take an optional `signal: AbortSignal` and pass it to Kysely with `abortable(signal)`. Lookups that should be cheap default it to `AbortSignal.timeout(CHEAP_QUERY_TIMEOUT_MS)`. +- Validate input with Zod schemas exported from the domain's `shared` entry point. + +## Tests + +- Write a few focused tests that check behaviour people rely on, such as "only an account's newest review counts", not implementation details like the exact SQL or which helper was called. +- Test through a module's entry points, so tests survive refactors behind them. +- Prefer real dependencies over mocks: database tests run against Postgres with the real migrations (`setUpTestDatabase`, needs Docker). Fake only what's outside the system, such as a user's PDS. diff --git a/web/package.json b/web/package.json index 756d2b0..0634f77 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,7 @@ "@atproto/api": "0.29.1", "@atproto/lex": "0.3.14", "@atproto/oauth-client-node": "0.5.11", + "@atproto/syntax": "0.7.7", "@observablehq/plot": "0.6.17", "@opentelemetry/api": "1.10.0", "@opentelemetry/instrumentation-pg": "0.77.0", @@ -41,10 +42,13 @@ "react-dom": "19.3.0", "server-only": "0.0.1", "xast-util-to-xml": "4.0.0", - "xastscript": "4.0.0" + "xastscript": "4.0.0", + "zod": "4.6.6" }, "devDependencies": { + "@atproto/common-web": "0.5.15", "@tailwindcss/turbopack": "4.3.3", + "@testcontainers/postgresql": "12.2.1", "@types/d3-geo": "3.1.1", "@types/geojson": "7946.0.16", "@types/negotiator": "0.6.5", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 5473b32..57e8d42 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: '@atproto/oauth-client-node': specifier: 0.5.11 version: 0.5.11 + '@atproto/syntax': + specifier: 0.7.7 + version: 0.7.7 '@observablehq/plot': specifier: 0.6.17 version: 0.6.17 @@ -247,10 +250,19 @@ importers: xastscript: specifier: 4.0.0 version: 4.0.0 + zod: + specifier: 4.6.6 + version: 4.6.6 devDependencies: + '@atproto/common-web': + specifier: 0.5.15 + version: 0.5.15 '@tailwindcss/turbopack': specifier: 4.3.3 version: 4.3.3 + '@testcontainers/postgresql': + specifier: 12.2.1 + version: 12.2.1(supports-color@7.2.0) '@types/d3-geo': specifier: 3.1.1 version: 3.1.1 @@ -514,6 +526,9 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': + resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@eloqnt/config@0.1.1': resolution: {integrity: sha512-YRDigSaiJEzKDfzTth5kG+L/PKfB3mo8h7yNzpXExPfvQmcD05WoZT57PdhJE0jI+Vva6eb0OCU8hnHHgdME3g==} @@ -601,6 +616,11 @@ packages: resolution: {integrity: sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g==} engines: {node: '>=12.10.0'} + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + '@grpc/proto-loader@0.8.1': resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} engines: {node: '>=6'} @@ -788,6 +808,10 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -807,6 +831,9 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + '@mapbox/jsonlint-lines-primitives@2.0.3': resolution: {integrity: sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==} engines: {node: '>= 22'} @@ -1187,6 +1214,10 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -1514,6 +1545,9 @@ packages: '@tailwindcss/turbopack@4.3.3': resolution: {integrity: sha512-FgL4wRgItNdXcDjcgYUSd59gNsWqNVK4GotjZimZMmt7OOIkRNqH2QEUcRXLdBDXvwTsi5rkYxDxzfrFUkmObQ==} + '@testcontainers/postgresql@12.2.1': + resolution: {integrity: sha512-MIbI5KjZ9M4SY4+xzxg/w4ZLBeV+Jd6ognXoWn7dh/mhIhBbn7ss0MiZHNg2XbCbu03BW9YQ9+H9xW8PFkJWaA==} + '@ts-morph/common@0.28.1': resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} @@ -1529,6 +1563,12 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/docker-modem@3.0.6': + resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} + + '@types/dockerode@4.0.2': + resolution: {integrity: sha512-hA4myZ0XzwHG/p/eCLElYsQS3y1XZfvi6AfLFKVp1SNpPkO6J3xLatacAFVkReTxXRoIa3eHJmhJi9w6I3+zNA==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -1544,6 +1584,9 @@ packages: '@types/negotiator@0.6.5': resolution: {integrity: sha512-MPOlB48mfWhoUlynY0ga7CFsXIPcH6vGPkjzXMn2p+4PH1QUyn2KPtw0hrLLmO6SaX4zse3X6h2x/083vveAlA==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@26.6.5': resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} @@ -1564,6 +1607,15 @@ packages: '@types/react@19.3.0': resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + '@types/ssh2-streams@0.1.13': + resolution: {integrity: sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==} + + '@types/ssh2@0.5.52': + resolution: {integrity: sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==} + + '@types/ssh2@1.15.6': + resolution: {integrity: sha512-oGdxhBqcRTwSTKFm+9EiKzkNVYRLEFkcW44lhguvBalGJbWfGnDt/ezwSUZc+SF9m9bMc3VyklNAtp7zICjS5w==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1763,6 +1815,10 @@ packages: '@vitest/spy@5.0.5': resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1792,6 +1848,14 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1831,6 +1895,9 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1842,6 +1909,12 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -1861,6 +1934,14 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + b4a@1.9.0: + resolution: {integrity: sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1868,20 +1949,69 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.9.2: + resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.8.4: + resolution: {integrity: sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==} + engines: {bare: '>=1.28.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.2: + resolution: {integrity: sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==} + + bare-stream@2.13.4: + resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.5.5: + resolution: {integrity: sha512-iM9qJCruvpt2rPaLsrumVDru9pogI7PHziHIa9kTNRFt8C8HuSa+kI9kFezK+NvHEFnnz71rKGZ1EAilq/3V/Q==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.11.29: resolution: {integrity: sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==} engines: {node: '>=6.0.0'} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bidi-js@1.1.0: resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} binary-search-bounds@2.0.5: resolution: {integrity: sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + brace-expansion@1.1.21: resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + brace-expansion@5.0.12: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} @@ -1895,6 +2025,24 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + + byline@5.0.0: + resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} + engines: {node: '>=0.10.0'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1935,6 +2083,9 @@ packages: character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + cjs-module-lexer@2.3.0: resolution: {integrity: sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==} @@ -1967,6 +2118,10 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1987,6 +2142,9 @@ packages: core-js@3.51.0: resolution: {integrity: sha512-7bDKx/5jm5MxzsXxc93g6Z2+4ZsuT/MddrfRmTDJNAkWsYWHU8EVNEsBiIEWnMAMCHMmRfUEjkuo3jeNBOIHRw==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cosmiconfig@9.0.2: resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} engines: {node: '>=14'} @@ -1996,6 +2154,19 @@ packages: typescript: optional: true + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2187,6 +2358,18 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + docker-compose@1.5.0: + resolution: {integrity: sha512-O+eD6c63+BJORUWddXA7uAlI6H1KLDPd/aS14k73nXYpFliM48C3xrrKB3sXsR1Fp/rHUZQe7+fJ+QA7+jepyw==} + engines: {node: '>= 6.0.0'} + + docker-modem@5.0.7: + resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==} + engines: {node: '>= 8.0'} + + dockerode@5.0.1: + resolution: {integrity: sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==} + engines: {node: '>= 14.17'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -2210,6 +2393,9 @@ packages: earcut@3.2.4: resolution: {integrity: sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + electron-to-chromium@1.5.459: resolution: {integrity: sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==} @@ -2222,6 +2408,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.26.0: resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} engines: {node: '>=10.13.0'} @@ -2407,6 +2596,17 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -2414,6 +2614,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -2459,6 +2662,13 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2494,6 +2704,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -2516,6 +2730,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2575,6 +2794,9 @@ packages: icu-minify@4.14.9: resolution: {integrity: sha512-PmO+fNnC3K3mfPKTnm3RrPERrhQZnjm1AlpyZQ0ShbXi8Y/kYZ3YOde8TEjCLz9k3P2DmzkBn8lykGrCAi1Ajw==} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2595,6 +2817,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -2703,6 +2928,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -2727,6 +2956,9 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -2743,6 +2975,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -2842,6 +3077,10 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3007,6 +3246,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -3049,9 +3291,29 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} @@ -3064,6 +3326,9 @@ packages: murmurhash-js@1.0.0: resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} + nan@2.30.0: + resolution: {integrity: sha512-P5mokDkSvCGBz6mZDD/2l+w9QoICRXdCyZ9ONGmTePr40oihJg3Kpn0YU3twKd6PJnwSlLBWNQeOT6yWEV2c4A==} + nanoid@3.3.20: resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3126,6 +3391,10 @@ packages: resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} engines: {node: '>=18'} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3166,6 +3435,9 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3182,6 +3454,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3204,6 +3479,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + pbf@5.1.2: resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==} hasBin: true @@ -3310,12 +3589,26 @@ packages: engines: {node: '>=14'} hasBin: true + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@5.1.1: resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + properties-reader@3.0.1: + resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==} + engines: {node: '>=18'} + protobufjs@7.6.6: resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} engines: {node: '>=12.0.0'} @@ -3323,6 +3616,9 @@ packages: protocol-buffers-schema@3.6.1: resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3348,6 +3644,20 @@ packages: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.8.0: + resolution: {integrity: sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + real-require@1.0.0: resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} @@ -3386,6 +3696,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3408,6 +3722,12 @@ packages: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -3490,6 +3810,13 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -3497,10 +3824,20 @@ packages: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} + split-ca@1.0.1: + resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + ssh-remote-port-forward@1.0.4: + resolution: {integrity: sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==} + + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -3511,10 +3848,17 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + streamx@2.28.1: + resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -3546,6 +3890,12 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -3593,6 +3943,29 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-fs@3.1.3: + resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.2.2: + resolution: {integrity: sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + testcontainers@12.2.1: + resolution: {integrity: sha512-JfbCyOvLdzatwgqUI3AvYCwRW0P5mCkKQVBPO8U5qSH1efC2kHvnfWmi9VHloy1rAFG2MEqWZObRH4bq0gU1kQ==} + engines: {node: '>= 22.22'} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thread-stream@4.2.0: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} @@ -3616,6 +3989,10 @@ packages: resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} hasBin: true + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3635,6 +4012,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3674,6 +4054,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} @@ -3713,6 +4096,9 @@ packages: '@types/react': optional: true + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + varint@6.0.0: resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} @@ -3834,10 +4220,17 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xast-util-to-xml@4.0.0: resolution: {integrity: sha512-r1euIWS5yZJUNpfN+ReE4m7Vld2iytaOPJtuXVuRQacRZwqRN1MAb+dv0aGLrdXOZrpGLyvUFf1Upyrb3R5qBg==} @@ -3880,6 +4273,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -3889,6 +4286,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.6.6: + resolution: {integrity: sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==} + zod@4.6.7: resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} @@ -4239,6 +4639,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@balena/dockerignore@1.0.2': {} + '@eloqnt/config@0.1.1': {} '@eloqnt/format-json@0.1.0': @@ -4345,6 +4747,13 @@ snapshots: '@grpc/proto-loader': 0.8.1 '@js-sdsl/ordered-map': 4.4.2 + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.6 + yargs: 17.7.3 + '@grpc/proto-loader@0.8.1': dependencies: lodash.camelcase: 4.3.0 @@ -4475,6 +4884,15 @@ snapshots: '@img/sharp-win32-x64@0.35.5': optional: true + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -4496,6 +4914,12 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} + '@kwsites/file-exists@1.1.1(supports-color@7.2.0)': + dependencies: + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + '@mapbox/jsonlint-lines-primitives@2.0.3': {} '@mapbox/point-geometry@1.1.0': {} @@ -4876,6 +5300,9 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -5084,6 +5511,15 @@ snapshots: '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 + '@testcontainers/postgresql@12.2.1(supports-color@7.2.0)': + dependencies: + testcontainers: 12.2.1(supports-color@7.2.0) + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + '@ts-morph/common@0.28.1': dependencies: minimatch: 10.2.6 @@ -5106,6 +5542,17 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/docker-modem@3.0.6': + dependencies: + '@types/node': 26.6.5 + '@types/ssh2': 1.15.6 + + '@types/dockerode@4.0.2': + dependencies: + '@types/docker-modem': 3.0.6 + '@types/node': 26.6.5 + '@types/ssh2': 1.15.6 + '@types/estree@1.0.9': {} '@types/geojson@7946.0.16': {} @@ -5116,6 +5563,10 @@ snapshots: '@types/negotiator@0.6.5': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@26.6.5': dependencies: undici-types: 8.9.0 @@ -5144,6 +5595,19 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/ssh2-streams@0.1.13': + dependencies: + '@types/node': 26.6.5 + + '@types/ssh2@0.5.52': + dependencies: + '@types/node': 26.6.5 + '@types/ssh2-streams': 0.1.13 + + '@types/ssh2@1.15.6': + dependencies: + '@types/node': 18.19.130 + '@types/unist@3.0.3': {} '@types/xast@2.0.4': @@ -5322,6 +5786,10 @@ snapshots: '@vitest/spy@5.0.5': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + acorn-jsx@5.3.2(acorn@8.19.0): dependencies: acorn: 8.19.0 @@ -5345,6 +5813,30 @@ snapshots: ansi-styles@6.2.3: {} + archiver-utils@5.0.2: + dependencies: + glob: 10.5.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.18.1 + normalize-path: 3.0.0 + readable-stream: 4.8.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.8.0 + readdir-glob: 1.1.3 + tar-stream: 3.2.2 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + argparse@2.0.1: {} aria-query@5.3.2: {} @@ -5416,12 +5908,20 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-types-flow@0.0.8: {} async-function@1.0.0: {} + async-lock@1.4.1: {} + + async@3.2.6: {} + atomic-sleep@1.0.0: {} available-typed-arrays@1.0.7: @@ -5434,23 +5934,70 @@ snapshots: axobject-query@4.1.0: {} + b4a@1.9.0: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} + bare-events@2.9.2: {} + + bare-fs@4.8.4: + dependencies: + bare-events: 2.9.2 + bare-path: 3.1.2 + bare-stream: 2.13.4(bare-events@2.9.2) + bare-url: 2.5.5 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.2: {} + + bare-stream@2.13.4(bare-events@2.9.2): + dependencies: + b4a: 1.9.0 + streamx: 2.28.1 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.5.5: + dependencies: + bare-path: 3.1.2 + + base64-js@1.5.1: {} + baseline-browser-mapping@2.11.29: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + bidi-js@1.1.0: dependencies: require-from-string: 2.0.2 binary-search-bounds@2.0.5: {} + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -5467,6 +6014,23 @@ snapshots: node-releases: 2.0.59 update-browserslist-db: 1.3.4(browserslist@4.29.3) + buffer-crc32@1.0.0: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buildcheck@0.0.7: + optional: true + + byline@5.0.0: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -5503,6 +6067,8 @@ snapshots: character-entities-legacy@3.0.0: {} + chownr@1.1.4: {} + cjs-module-lexer@2.3.0: {} client-only@0.0.1: {} @@ -5531,6 +6097,14 @@ snapshots: commander@7.2.0: {} + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.8.0 + concat-map@0.0.1: {} content-disposition@2.0.1: {} @@ -5543,6 +6117,8 @@ snapshots: core-js@3.51.0: {} + core-util-is@1.0.3: {} + cosmiconfig@9.0.2(typescript@6.0.3): dependencies: env-paths: 2.2.1 @@ -5552,6 +6128,19 @@ snapshots: optionalDependencies: typescript: 6.0.3 + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.30.0 + optional: true + + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.8.0 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5768,6 +6357,30 @@ snapshots: diff@8.0.4: {} + docker-compose@1.5.0: + dependencies: + yaml: 2.9.1 + + docker-modem@5.0.7(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + readable-stream: 3.6.2 + split-ca: 1.0.1 + ssh2: 1.17.0 + transitivePeerDependencies: + - supports-color + + dockerode@5.0.1(supports-color@7.2.0): + dependencies: + '@balena/dockerignore': 1.0.2 + '@grpc/grpc-js': 1.14.6 + '@grpc/proto-loader': 0.7.15 + docker-modem: 5.0.7(supports-color@7.2.0) + protobufjs: 7.6.6 + tar-fs: 2.1.5 + transitivePeerDependencies: + - supports-color + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -5788,6 +6401,8 @@ snapshots: earcut@3.2.4: {} + eastasianwidth@0.2.0: {} + electron-to-chromium@1.5.459: {} emoji-regex@10.6.0: {} @@ -5796,6 +6411,10 @@ snapshots: emoji-regex@9.2.2: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.26.0: dependencies: graceful-fs: 4.2.11 @@ -6135,10 +6754,22 @@ snapshots: esutils@2.0.3: {} + event-target-shim@5.0.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + expect-type@1.4.0: {} fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6183,6 +6814,13 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fs-constants@1.0.0: {} + fsevents@2.3.3: optional: true @@ -6223,6 +6861,8 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 + get-port@5.1.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -6248,6 +6888,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@14.0.0: {} globals@16.4.0: {} @@ -6297,6 +6946,8 @@ snapshots: dependencies: '@formatjs/icu-messageformat-parser': 3.5.22 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.12: {} @@ -6314,6 +6965,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -6427,6 +7080,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream@2.0.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -6453,6 +7108,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -6470,6 +7127,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jiti@2.7.0: {} jose@5.10.0: {} @@ -6536,6 +7199,10 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -6649,6 +7316,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.18.1: {} + long@5.3.2: {} loose-envify@1.4.0: @@ -6707,8 +7376,22 @@ snapshots: dependencies: brace-expansion: 1.1.21 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.7 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.7 + minimist@1.2.8: {} + minipass@7.1.3: {} + + mkdirp-classic@0.5.3: {} + + mkdirp@3.0.1: {} + module-details-from-path@1.0.4: {} ms@2.1.3: {} @@ -6717,6 +7400,9 @@ snapshots: murmurhash-js@1.0.0: {} + nan@2.30.0: + optional: true + nanoid@3.3.20: {} napi-postinstall@0.3.4: {} @@ -6785,6 +7471,8 @@ snapshots: node-releases@2.0.59: {} + normalize-path@3.0.0: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -6831,6 +7519,10 @@ snapshots: on-exit-leak-free@2.1.2: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6855,6 +7547,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -6874,6 +7568,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + pbf@5.1.2: dependencies: resolve-protobuf-schema: 2.1.0 @@ -6973,14 +7672,31 @@ snapshots: prettier@3.9.10: {} + process-nextick-args@2.0.1: {} + process-warning@5.1.1: {} + process@0.11.10: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + properties-reader@3.0.1(supports-color@7.2.0): + dependencies: + '@kwsites/file-exists': 1.1.1(supports-color@7.2.0) + mkdirp: 3.0.1 + transitivePeerDependencies: + - supports-color + protobufjs@7.6.6: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -6997,6 +7713,11 @@ snapshots: protocol-buffers-schema@3.6.1: {} + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -7014,6 +7735,34 @@ snapshots: react@19.3.0: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.8.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + real-require@1.0.0: {} reflect.getprototypeof@1.0.10: @@ -7064,6 +7813,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + retry@0.12.0: {} + reusify@1.1.0: {} robust-predicates@3.0.3: {} @@ -7103,6 +7854,10 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -7222,14 +7977,33 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 source-map-js@1.2.2: {} + split-ca@1.0.1: {} + split2@4.2.0: {} + ssh-remote-port-forward@1.0.4: + dependencies: + '@types/ssh2': 0.5.52 + ssh2: 1.17.0 + + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.30.0 + stable-hash@0.0.5: {} std-env@4.3.0: {} @@ -7239,12 +8013,27 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + streamx@2.28.1: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -7307,6 +8096,14 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -7341,6 +8138,80 @@ snapshots: tapable@2.3.3: {} + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-fs@3.1.3: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.2 + optionalDependencies: + bare-fs: 4.8.4 + bare-path: 3.1.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.2.2: + dependencies: + b4a: 1.9.0 + bare-fs: 4.8.4 + fast-fifo: 1.3.2 + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + testcontainers@12.2.1(supports-color@7.2.0): + dependencies: + '@balena/dockerignore': 1.0.2 + '@types/dockerode': 4.0.2 + archiver: 7.0.1 + async-lock: 1.4.1 + byline: 5.0.0 + debug: 4.4.3(supports-color@7.2.0) + docker-compose: 1.5.0 + dockerode: 5.0.1(supports-color@7.2.0) + get-port: 5.1.1 + proper-lockfile: 4.1.2 + properties-reader: 3.0.1(supports-color@7.2.0) + ssh-remote-port-forward: 1.0.4 + tar-fs: 3.1.3 + tmp: 0.2.7 + undici: 8.12.3 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + text-decoder@1.2.7: + dependencies: + b4a: 1.9.0 + transitivePeerDependencies: + - react-native-b4a + thread-stream@4.2.0: dependencies: real-require: 1.0.0 @@ -7358,6 +8229,8 @@ snapshots: tlds@1.261.0: {} + tmp@0.2.7: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -7380,6 +8253,8 @@ snapshots: tslib@2.8.1: {} + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -7440,6 +8315,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@5.26.5: {} + undici-types@8.9.0: {} undici@6.29.1: {} @@ -7497,6 +8374,8 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 + util-deprecate@1.0.2: {} + varint@6.0.0: {} vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1): @@ -7589,12 +8468,20 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 strip-ansi: 7.2.0 + wrappy@1.0.2: {} + xast-util-to-xml@4.0.0: dependencies: '@types/xast': 2.0.4 @@ -7638,10 +8525,18 @@ snapshots: yocto-queue@0.1.0: {} + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.8.0 + zod-validation-error@4.0.2(zod@4.6.7): dependencies: zod: 4.6.7 zod@3.25.76: {} + zod@4.6.6: {} + zod@4.6.7: {} diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml index 79b9d8f..e844b60 100644 --- a/web/pnpm-workspace.yaml +++ b/web/pnpm-workspace.yaml @@ -2,6 +2,8 @@ allowBuilds: "@parcel/watcher": false "@swc/core": false core-js: false + cpu-features: false protobufjs: false sharp: false + ssh2: false unrs-resolver: false diff --git a/web/src/app/routes/[did]/[rkey]/page.tsx b/web/src/app/routes/[did]/[rkey]/page.tsx index 1262cb7..ec9e8da 100644 --- a/web/src/app/routes/[did]/[rkey]/page.tsx +++ b/web/src/app/routes/[did]/[rkey]/page.tsx @@ -18,10 +18,11 @@ import { ReviewRating } from "@/components/review-rating"; import { RouteMap } from "@/components/route-map"; import { getSession } from "@/auth/session"; import { ShareButton } from "@/components/share-button"; +import { loadLabelledUris } from "@/lib/moderation"; import { - fetchUserReview, - fetchReviewSummary, - fetchReviews, + loadReviewSummary, + loadReviews, + loadViewerReview, } from "@/lib/reviews"; import { fetchRoute, type Route } from "@/lib/routes"; import { unitsForLocale } from "@/i18n/units"; @@ -136,7 +137,7 @@ async function RouteDetails({ params }: Pick) { const [session, author, reviewSummary, format, locale] = await Promise.all([ getSession(), fetchAuthorProfile(route.did), - fetchReviewSummary(route.uri), + loadReviewSummary(route, { labelled: "exclude" }), getFormatter(), getLocale(), ]); @@ -280,20 +281,27 @@ async function RouteDetails({ params }: Pick) { async function RouteReviews({ route }: { route: Route }) { const session = await getSession(); - const viewerDid = session?.did; + const viewer = session ? { did: session.did } : null; - const [reviews, viewerReview, format] = await Promise.all([ - fetchReviews(route.uri, { - limit: viewerDid === undefined ? 1 : 10, - viewerDid, + const [viewerReview, latestReviews, format] = await Promise.all([ + loadViewerReview(route, viewer, { labelled: "include" }), + loadReviews(route, { + labelled: "exclude", + limit: viewer === null ? 1 : 10, }), - viewerDid ? fetchUserReview(route.uri, viewerDid) : null, getFormatter(), ]); + const reviews = viewerReview + ? [ + viewerReview, + ...latestReviews.filter(({ author }) => author !== viewer?.did), + ] + : latestReviews; - const authors = await fetchAuthorProfiles( - reviews.map((review) => review.did), - ); + const [authors, labelled] = await Promise.all([ + fetchAuthorProfiles(reviews.map((review) => review.author)), + loadLabelledUris(reviews), + ]); return (
Reviews - {viewerDid && viewerDid !== route.did && !viewerReview && ( + {viewer && viewer.did !== route.did && !viewerReview && ( - {!viewerDid && ( + {!viewer && (

Log in to review this route and to read all reviews. @@ -327,7 +335,7 @@ async function RouteReviews({ route }: { route: Route }) { ) : (

    {reviews.map((review, index) => ( -
  1. +
  2. @@ -337,7 +345,7 @@ async function RouteReviews({ route }: { route: Route }) { })} - {review.did === viewerDid && ( + {review.author === viewer?.did && ( )}
    - {review.labelled && } + {labelled.has(review.uri) && } {review.text && (

    {review.text}

    diff --git a/web/src/app/routes/[did]/[rkey]/review/actions.ts b/web/src/app/routes/[did]/[rkey]/review/actions.ts index 0fcdf02..0cdd7ba 100644 --- a/web/src/app/routes/[did]/[rkey]/review/actions.ts +++ b/web/src/app/routes/[did]/[rkey]/review/actions.ts @@ -1,15 +1,17 @@ "use server"; -import { Client, currentDatetimeString } from "@atproto/lex"; +import { AtUri } from "@atproto/syntax"; import { redirect } from "next/navigation"; -import { loginHref } from "@/auth/login-href"; -import { getSession } from "@/auth/session"; -import { fetchUserReview } from "@/lib/reviews"; -import { fetchRoute } from "@/lib/routes"; +import { assertSession } from "@/auth/session"; import { app } from "@/lexicons"; -import { logger } from "@/lib/logger"; - -const { review } = app.attrails; +import { + deleteReview, + type DeleteReviewError, + reviewSchema, + saveReview, + type SaveReviewError, +} from "@/lib/reviews"; +import { fetchRoute } from "@/lib/routes"; export type ReviewValues = { rating: string; @@ -21,16 +23,21 @@ export type ReviewState = { error?: string; }; -export async function saveReview( +const SAVE_ERROR_MESSAGES: Record = { + "self-review": "You can't review your own route.", + invalid: "Check your review and try again.", + "write-failed": + "We couldn't save your review to your account. Please try again.", +}; + +export async function saveRouteReview( routeDid: string, routeRkey: string, _previous: ReviewState, formData: FormData, ): Promise { - const session = await getSession(); - if (!session) { - redirect(loginHref(`/routes/${routeDid}/${routeRkey}/review`)); - } + const routePath = `/routes/${routeDid}/${routeRkey}`; + const session = await assertSession(`${routePath}/review`); const values: ReviewValues = { rating: text(formData, "rating"), @@ -41,82 +48,45 @@ export async function saveReview( if (!route) { return { values, error: "This route no longer exists." }; } - if (route.did === session.did) { - return { values, error: "You can't review your own route." }; - } - const rating = Number(values.rating); - if (!Number.isInteger(rating) || rating < 1 || rating > 5) { - return { values, error: "Choose a rating from 1 to 5 stars." }; - } - if (!values.text) { - return { values, error: "Write a few words about the route." }; - } - - const record = review.$safeValidate({ - $type: review.$type, - subject: { uri: route.uri, cid: route.cid }, - rating, - text: values.text, - createdAt: currentDatetimeString(), - }); - if (!record.success) { - return { values, error: `Check your review: ${record.reason.message}` }; + const draft = reviewSchema.safeParse(values); + if (!draft.success) { + return { values, error: draft.error.issues[0].message }; } - const client = new Client(session); - let latest; - try { - latest = await fetchUserReview(route.uri, session.did); - if (latest) { - await client.put(review, record.value, { rkey: latest.rkey }); - } else { - await client.create(review, record.value); - } - } catch (error) { - logger.warn( - { err: error, did: session.did, route: route.uri }, - "reviews:save", - ); - return { - values, - error: "We couldn't save your review to your account. Please try again.", - }; + const result = await saveReview(session, route, draft.data); + if (!result.ok) { + return { values, error: SAVE_ERROR_MESSAGES[result.reason] }; } - const notice = latest ? "review-updated" : "review-posted"; - redirect(`/routes/${route.did}/${route.rkey}?notice=${notice}`); + const notice = result.outcome === "created" ? "posted" : "updated"; + redirect(`${routePath}?notice=review-${notice}`); } export type DeleteReviewState = { error?: string; }; -export async function deleteReview( +const DELETE_ERROR_MESSAGES: Record = { + "not-found": "You haven't reviewed this route.", + "write-failed": + "We couldn't delete your review from your account. Please try again.", +}; + +export async function deleteRouteReview( routeDid: string, routeRkey: string, - reviewRkey: string, ): Promise { - const session = await getSession(); - if (!session) { - redirect(loginHref(`/routes/${routeDid}/${routeRkey}/review`)); - } + const routePath = `/routes/${routeDid}/${routeRkey}`; + const session = await assertSession(`${routePath}/review`); - const client = new Client(session); - try { - await client.delete(review, { rkey: reviewRkey }); - } catch (error) { - logger.warn( - { err: error, did: session.did, rkey: reviewRkey }, - "reviews:delete", - ); - return { - error: - "We couldn't delete your review from your account. Please try again.", - }; + const subject = AtUri.make(routeDid, app.attrails.route.$type, routeRkey); + const result = await deleteReview(session, { uri: subject.toString() }); + if (!result.ok) { + return { error: DELETE_ERROR_MESSAGES[result.reason] }; } - redirect(`/routes/${routeDid}/${routeRkey}?notice=review-deleted`); + redirect(`${routePath}?notice=review-deleted`); } function text(formData: FormData, key: string): string { diff --git a/web/src/app/routes/[did]/[rkey]/review/page.tsx b/web/src/app/routes/[did]/[rkey]/review/page.tsx index 9f161fa..d920a57 100644 --- a/web/src/app/routes/[did]/[rkey]/review/page.tsx +++ b/web/src/app/routes/[did]/[rkey]/review/page.tsx @@ -2,12 +2,12 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound, redirect } from "next/navigation"; import { Suspense } from "react"; -import { loginHref } from "@/auth/login-href"; -import { getSession } from "@/auth/session"; +import { assertSession } from "@/auth/session"; import { ReviewModerationNotice } from "@/components/moderation-notice"; -import { fetchUserReview } from "@/lib/reviews"; +import { loadLabelledUris } from "@/lib/moderation"; +import { loadViewerReview } from "@/lib/reviews"; import { fetchRoute } from "@/lib/routes"; -import { deleteReview, saveReview } from "./actions"; +import { deleteRouteReview, saveRouteReview } from "./actions"; import { DeleteReview } from "./delete-review"; import { ReviewForm } from "./review-form"; @@ -36,10 +36,7 @@ async function ManageReview({ params }: Pick) { const did = decodeURIComponent(encodedDid); const routePath = `/routes/${did}/${rkey}`; - const session = await getSession(); - if (!session) { - redirect(loginHref(`${routePath}/review`)); - } + const session = await assertSession(`${routePath}/review`); const route = await fetchRoute(did, rkey); if (!route) { @@ -50,7 +47,14 @@ async function ManageReview({ params }: Pick) { redirect(routePath); } - const review = await fetchUserReview(route.uri, session.did); + const review = await loadViewerReview( + route, + { did: session.did }, + { labelled: "include" }, + ); + const labelled = review + ? (await loadLabelledUris([review])).has(review.uri) + : false; return ( <> @@ -64,9 +68,9 @@ async function ManageReview({ params }: Pick) { .

    - {review?.labelled && } + {labelled && } ) { /> {review && ( )} diff --git a/web/src/app/users/listings.ts b/web/src/app/users/listings.ts index 0579daf..b2ce049 100644 --- a/web/src/app/users/listings.ts +++ b/web/src/app/users/listings.ts @@ -1,8 +1,10 @@ import "server-only"; import { getFormatter } from "next-intl/server"; -import { fetchReviewsByAuthor } from "@/lib/reviews"; -import { fetchRoutesByAuthor } from "@/lib/routes"; +import { AtUri } from "@atproto/syntax"; +import { loadLabelledUris } from "@/lib/moderation"; +import { loadAuthorReviews } from "@/lib/reviews"; +import { fetchRoutesByAuthor, loadRouteNames } from "@/lib/routes"; import { activityLabel } from "@/lib/labels"; import type { Page } from "@/lib/pages"; @@ -19,7 +21,7 @@ export type RouteListItem = { }; export type ReviewListItem = { - rkey: string; + uri: string; rating: number; text: string | null; createdAt: string; @@ -64,22 +66,40 @@ export async function fetchReviewList( cursor?: string, ): Promise> { const [page, format] = await Promise.all([ - fetchReviewsByAuthor(did, { cursor, limit: REVIEWS_PAGE_SIZE }), + loadAuthorReviews(did, { + labelled: "include", + cursor, + limit: REVIEWS_PAGE_SIZE, + }), getFormatter(), ]); + const [routeNames, labelled] = await Promise.all([ + loadRouteNames(page.items.map((review) => review.subject.uri)), + loadLabelledUris(page.items), + ]); return { ...page, - items: page.items.map((review) => ({ - rkey: review.rkey, - rating: review.rating, - text: review.text, - createdAt: review.createdAt.toString(), - createdOn: format.dateTime(review.createdAt.epochMilliseconds, { - dateStyle: "long", - }), - labelled: review.labelled, - route: review.route, - })), + // Reviews of routes that have been deleted are left out. + items: page.items.flatMap((review) => { + const name = routeNames.get(review.subject.uri); + if (name === undefined) { + return []; + } + + const route = new AtUri(review.subject.uri); + + return { + uri: review.uri, + rating: review.rating, + text: review.text, + createdAt: review.createdAt.toString(), + createdOn: format.dateTime(review.createdAt.epochMilliseconds, { + dateStyle: "long", + }), + labelled: labelled.has(review.uri), + route: { did: route.hostname, rkey: route.rkey, name }, + }; + }), }; } diff --git a/web/src/app/users/me/review-list.tsx b/web/src/app/users/me/review-list.tsx index 254e816..7899ff6 100644 --- a/web/src/app/users/me/review-list.tsx +++ b/web/src/app/users/me/review-list.tsx @@ -23,7 +23,7 @@ export function ReviewList({ firstPage }: ReviewListProps) { const routePath = `/routes/${review.route.did}/${review.route.rkey}`; return ( -
  3. +
  4. diff --git a/web/src/auth/session.ts b/web/src/auth/session.ts index f339ccb..2de7c0a 100644 --- a/web/src/auth/session.ts +++ b/web/src/auth/session.ts @@ -4,9 +4,11 @@ import { createHash, randomBytes } from "node:crypto"; import type { OAuthSession } from "@atproto/oauth-client-node"; import { sql } from "kysely"; import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; import { cache } from "react"; import { getDb } from "@/db/client"; import { getOAuthClient, getWebUrl, OAUTH_SCOPE } from "./client"; +import { loginHref } from "./login-href"; import { logger } from "@/lib/logger"; const SESSION_COOKIE = "attrails_session"; @@ -60,6 +62,18 @@ export const getSession = cache(async (): Promise => { return session; }); +/** + * Returns the signed-in account's OAuth session. When signed out, redirects to + * the login page, set up to come back to `returnTo`, by throwing. + */ +export async function assertSession(returnTo: string): Promise { + const session = await getSession(); + if (!session) { + redirect(loginHref(returnTo)); + } + return session; +} + /** * Signs this browser in as `did`. Call only from a Server Function or Route * Handler, after the OAuth session for `did` has been stored. diff --git a/web/src/db/client.ts b/web/src/db/client.ts index 5ac83ad..b720cb0 100644 --- a/web/src/db/client.ts +++ b/web/src/db/client.ts @@ -1,5 +1,10 @@ import "server-only"; -import { Kysely, PostgresDialect, type LogEvent } from "kysely"; +import { + type AbortableQueryOptions, + Kysely, + PostgresDialect, + type LogEvent, +} from "kysely"; import { connection } from "next/server"; import { Pool, types, type CustomTypesConfig } from "pg"; import type { DB } from "./types"; @@ -11,6 +16,20 @@ export type AttrailsDb = Kysely; const SLOW_QUERY_MS = 250; +/** + * Default timeout for queries that should be cheap, such as lookups by key. + * Well under the connection's `statement_timeout`. + */ +export const CHEAP_QUERY_TIMEOUT_MS = 1_000; + +/** + * Query options that abort with `signal`, cancelling the query in Postgres + * rather than leaving it running. + */ +export function abortable(signal?: AbortSignal): AbortableQueryOptions { + return { signal, inflightQueryAbortStrategy: "cancel query" }; +} + // Logs query text without parameter values, which may contain user data. function logSlowQuery(event: LogEvent): void { if (event.level !== "query" || event.queryDurationMillis < SLOW_QUERY_MS) { diff --git a/web/src/lib/identity.ts b/web/src/lib/identity.ts new file mode 100644 index 0000000..1dd7ec1 --- /dev/null +++ b/web/src/lib/identity.ts @@ -0,0 +1,2 @@ +/** Whoever is looking at a page: a signed-in account, or `null` when signed out. */ +export type Viewer = { did: string } | null; diff --git a/web/src/lib/moderation.ts b/web/src/lib/moderation.ts index e972370..ad9369c 100644 --- a/web/src/lib/moderation.ts +++ b/web/src/lib/moderation.ts @@ -1,6 +1,13 @@ import "server-only"; import { type RawBuilder, sql } from "kysely"; +import { abortable, CHEAP_QUERY_TIMEOUT_MS, getDb } from "@/db/client"; + +/** + * Which records to keep based on their moderation labels: all of them, only + * those without labels, or only those with labels. + */ +export type LabelledFilter = "include" | "exclude" | "only"; /** * Whether the version of a record identified by the `uri` and `cid` columns @@ -14,3 +21,53 @@ export function isLabelled(uri: string, cid: string): RawBuilder { WHERE labels.uri = ${sql.ref(uri)} AND labels.cid = ${sql.ref(cid)} )`; } + +/** + * Matches the records identified by the `uri` and `cid` columns that `filter` + * keeps. Qualify the column names like for `isLabelled`. + */ +export function matchesLabelled( + filter: LabelledFilter, + uri: string, + cid: string, +): RawBuilder { + switch (filter) { + case "include": + return sql`TRUE`; + case "exclude": + return sql`NOT ${isLabelled(uri, cid)}`; + case "only": + return isLabelled(uri, cid); + } +} + +/** + * Loads which of `records` have moderation labels on the given version, + * returned as a set of their URIs. + */ +export async function loadLabelledUris( + records: readonly { uri: string; cid: string }[], + { + signal = AbortSignal.timeout(CHEAP_QUERY_TIMEOUT_MS), + }: { signal?: AbortSignal } = {}, +): Promise> { + if (records.length === 0) { + return new Set(); + } + + const db = await getDb(); + const rows = await db + .selectFrom("labels") + .select("uri") + .distinct() + .where((eb) => + eb( + eb.refTuple("uri", "cid"), + "in", + records.map(({ uri, cid }) => eb.tuple(uri, cid)), + ), + ) + .execute(abortable(signal)); + + return new Set(rows.map((row) => row.uri)); +} diff --git a/web/src/lib/reviews.ts b/web/src/lib/reviews.ts deleted file mode 100644 index f62cd82..0000000 --- a/web/src/lib/reviews.ts +++ /dev/null @@ -1,201 +0,0 @@ -import "server-only"; - -import { sql } from "kysely"; -import { cache } from "react"; -import { type AttrailsDb, getDb } from "@/db/client"; -import { isLabelled } from "@/lib/moderation"; -import type { Page } from "@/lib/pages"; -import { after, decodeCursor, toPage } from "./pagination"; - -export type Review = { - did: string; - rkey: string; - rating: number; - text: string | null; - createdAt: Temporal.Instant; - /** Whether this version of the review has moderation labels. */ - labelled: boolean; -}; - -export type ReviewSummary = { - count: number; - /** `null` if the route has no reviews. */ - averageRating: number | null; -}; - -// An account can keep several reviews of the same route in its repository. -// Only the newest one counts, and authors can't review their own routes. -function latestReviews(db: AttrailsDb, routeUri: string) { - return db - .selectFrom("reviews") - .innerJoin("routes", "routes.uri", "reviews.subject_uri") - .distinctOn("reviews.did") - .select([ - "reviews.uri", - "reviews.cid", - "reviews.did", - "reviews.rkey", - "reviews.rating", - "reviews.body as text", - "reviews.created_at as createdAt", - "reviews.sort_at as sortAt", - ]) - .where("reviews.subject_uri", "=", routeUri) - .where("reviews.tombstoned", "=", false) - .whereRef("reviews.did", "<>", "routes.did") - .orderBy("reviews.did") - .orderBy("reviews.created_at", "desc") - .orderBy("reviews.rkey", "desc"); -} - -/** - * Summarises a route's reviews, leaving out reviews with moderation labels. - */ -export const fetchReviewSummary = cache( - async (routeUri: string): Promise => { - const db = await getDb(); - - return db - .selectFrom(latestReviews(db, routeUri).as("latest")) - .where((eb) => eb.not(isLabelled("latest.uri", "latest.cid"))) - .select([ - sql`COUNT(*)::int`.as("count"), - sql`AVG(rating)::float8`.as("averageRating"), - ]) - .executeTakeFirstOrThrow(); - }, -); - -/** - * Fetches a route's reviews, newest first. Reviews with moderation labels are - * left out, unless `viewerDid` wrote them. - */ -export async function fetchReviews( - routeUri: string, - { limit = 10, viewerDid }: { limit?: number; viewerDid?: string }, -): Promise { - const db = await getDb(); - - return db - .selectFrom(latestReviews(db, routeUri).as("latest")) - .where((eb) => { - const unlabelled = eb.not(isLabelled("latest.uri", "latest.cid")); - return viewerDid - ? eb.or([unlabelled, eb("latest.did", "=", viewerDid)]) - : unlabelled; - }) - .select([ - "did", - "rkey", - "rating", - "text", - "createdAt", - isLabelled("latest.uri", "latest.cid").as("labelled"), - ]) - .orderBy("sortAt", "desc") - .limit(limit) - .execute(); -} - -/** - * Fetches `did`'s newest review of a route, or `null` if they haven't - * reviewed it. - */ -export async function fetchUserReview( - routeUri: string, - did: string, -): Promise { - const db = await getDb(); - - const row = await db - .selectFrom("reviews") - .select([ - "did", - "rkey", - "rating", - "body as text", - "created_at as createdAt", - isLabelled("reviews.uri", "reviews.cid").as("labelled"), - ]) - .where("subject_uri", "=", routeUri) - .where("did", "=", did) - .where("tombstoned", "=", false) - .orderBy("created_at", "desc") - .orderBy("rkey", "desc") - .executeTakeFirst(); - - return row ?? null; -} - -export type AuthoredReview = Review & { - route: { did: string; rkey: string; name: string }; -}; - -/** - * Fetches a page of `did`'s reviews, newest first. Like on a route's page, - * only the newest review of each route counts. - */ -export async function fetchReviewsByAuthor( - did: string, - { cursor, limit }: { cursor?: string; limit: number }, -): Promise> { - const db = await getDb(); - const position = cursor ? decodeCursor(cursor) : undefined; - if (position === null) { - return { items: [], nextCursor: null }; - } - - const latest = db - .selectFrom("reviews") - .innerJoin("routes", "routes.uri", "reviews.subject_uri") - .distinctOn("reviews.subject_uri") - .select([ - "reviews.did", - "reviews.rkey", - "reviews.rating", - "reviews.body as text", - "reviews.created_at as createdAt", - "reviews.sort_at as sortAt", - isLabelled("reviews.uri", "reviews.cid").as("labelled"), - "routes.did as routeDid", - "routes.rkey as routeRkey", - "routes.name as routeName", - ]) - .where("reviews.did", "=", did) - .where("reviews.tombstoned", "=", false) - .where("routes.tombstoned", "=", false) - .where("routes.did", "<>", did) - .orderBy("reviews.subject_uri") - .orderBy("reviews.created_at", "desc") - .orderBy("reviews.rkey", "desc"); - - let query = db.selectFrom(latest.as("latest")).selectAll(); - if (position) { - query = query.where(after("sortAt", "did", "rkey", position)); - } - - const rows = await query - .orderBy("sortAt", "desc") - .orderBy("did", "desc") - .orderBy("rkey", "desc") - .limit(limit + 1) - .execute(); - - const page = toPage(rows, limit, ({ sortAt, did, rkey }) => ({ - sortAt, - did, - rkey, - })); - return { - ...page, - items: page.items.map((row) => ({ - did: row.did, - rkey: row.rkey, - rating: row.rating, - text: row.text, - createdAt: row.createdAt, - labelled: row.labelled, - route: { did: row.routeDid, rkey: row.routeRkey, name: row.routeName }, - })), - }; -} diff --git a/web/src/lib/reviews/index.ts b/web/src/lib/reviews/index.ts new file mode 100644 index 0000000..6d34957 --- /dev/null +++ b/web/src/lib/reviews/index.ts @@ -0,0 +1,20 @@ +import "server-only"; + +// Everything the app reads, validates, and writes about reviews. Client +// components import `@/lib/reviews/shared` instead. + +export * from "./shared"; +export { + loadAuthorReviews, + loadReviewSummary, + loadReviews, + loadViewerReview, +} from "./queries"; +export { + deleteReview, + type DeleteReviewError, + type DeleteReviewResult, + saveReview, + type SaveReviewError, + type SaveReviewResult, +} from "./records"; diff --git a/web/src/lib/reviews/queries.ts b/web/src/lib/reviews/queries.ts new file mode 100644 index 0000000..dc69596 --- /dev/null +++ b/web/src/lib/reviews/queries.ts @@ -0,0 +1,198 @@ +import "server-only"; + +import { AtUri } from "@atproto/syntax"; +import { sql } from "kysely"; +import { + abortable, + type AttrailsDb, + CHEAP_QUERY_TIMEOUT_MS, + getDb, +} from "@/db/client"; +import type { Viewer } from "@/lib/identity"; +import { type LabelledFilter, matchesLabelled } from "@/lib/moderation"; +import type { Page } from "@/lib/pages"; +import { after, decodeCursor, toPage } from "@/lib/pagination"; +import type { AuthoredReview, Review, ReviewSummary, Subject } from "./shared"; + +type SubjectUri = Pick; + +const REVIEW_COLUMNS = [ + "reviews.uri", + "reviews.cid", + "reviews.did as author", + "reviews.rkey", + "reviews.rating", + "reviews.body as text", + "reviews.created_at as createdAt", + "reviews.sort_at as sortAt", +] as const; + +// An account can keep several reviews of the same subject in its repository. +// Only the newest one counts, so labels are filtered after picking it: an older +// version never stands in for a newest one that's filtered out. +// +// Authors can't review their own records, and the subject's author is the +// account in its URI. +function latestReviews(db: AttrailsDb, subject: SubjectUri) { + return db + .selectFrom("reviews") + .distinctOn("reviews.did") + .select(REVIEW_COLUMNS) + .where("reviews.subject_uri", "=", subject.uri) + .where("reviews.tombstoned", "=", false) + .where("reviews.did", "<>", new AtUri(subject.uri).hostname) + .orderBy("reviews.did") + .orderBy("reviews.created_at", "desc") + .orderBy("reviews.rkey", "desc"); +} + +/** Loads the newest review of `subject` by each account, newest first. */ +export async function loadReviews( + subject: SubjectUri, + { + labelled, + limit, + signal, + }: { labelled: LabelledFilter; limit: number; signal?: AbortSignal }, +): Promise { + const db = await getDb(); + + return db + .selectFrom(latestReviews(db, subject).as("latest")) + .select(["uri", "cid", "author", "rating", "text", "createdAt"]) + .where(matchesLabelled(labelled, "latest.uri", "latest.cid")) + .orderBy("sortAt", "desc") + .orderBy("author", "desc") + .limit(limit) + .execute(abortable(signal)); +} + +/** Summarises the ratings of the reviews `loadReviews` would load. */ +export async function loadReviewSummary( + subject: SubjectUri, + { labelled, signal }: { labelled: LabelledFilter; signal?: AbortSignal }, +): Promise { + const db = await getDb(); + + const [summary] = await db + .selectFrom(latestReviews(db, subject).as("latest")) + .select([ + sql`COUNT(*)::int`.as("count"), + sql`AVG(rating)::float8`.as("averageRating"), + ]) + .where(matchesLabelled(labelled, "latest.uri", "latest.cid")) + .execute(abortable(signal)); + return summary; +} + +/** + * Loads the viewer's newest review of `subject`, or `null` if they're signed + * out, haven't reviewed it, or `labelled` filters it out. + */ +export async function loadViewerReview( + subject: SubjectUri, + viewer: Viewer, + { + labelled, + signal = AbortSignal.timeout(CHEAP_QUERY_TIMEOUT_MS), + }: { labelled: LabelledFilter; signal?: AbortSignal }, +): Promise { + if (!viewer) { + return null; + } + + const db = await getDb(); + const latest = db + .selectFrom("reviews") + .select(REVIEW_COLUMNS) + .where("reviews.subject_uri", "=", subject.uri) + .where("reviews.did", "=", viewer.did) + .where("reviews.tombstoned", "=", false) + .orderBy("reviews.created_at", "desc") + .orderBy("reviews.rkey", "desc") + .limit(1); + + const row = await db + .selectFrom(latest.as("latest")) + .select(["uri", "cid", "author", "rating", "text", "createdAt"]) + .where(matchesLabelled(labelled, "latest.uri", "latest.cid")) + .executeTakeFirst(abortable(signal)); + + return row ?? null; +} + +/** + * Loads a page of `author`'s reviews, newest first. Like for a subject, only + * the newest review of each subject counts, and reviews of the author's own + * records are left out. + */ +export async function loadAuthorReviews( + author: string, + { + labelled, + cursor, + limit, + signal, + }: { + labelled: LabelledFilter; + cursor?: string; + limit: number; + signal?: AbortSignal; + }, +): Promise> { + const position = cursor ? decodeCursor(cursor) : undefined; + if (position === null) { + return { items: [], nextCursor: null }; + } + + const db = await getDb(); + const latest = db + .selectFrom("reviews") + .distinctOn("reviews.subject_uri") + .select([ + ...REVIEW_COLUMNS, + "reviews.subject_uri as subjectUri", + "reviews.subject_cid as subjectCid", + ]) + .where("reviews.did", "=", author) + .where("reviews.tombstoned", "=", false) + .where( + sql`NOT starts_with(reviews.subject_uri, ${`at://${author}/`})`, + ) + .orderBy("reviews.subject_uri") + .orderBy("reviews.created_at", "desc") + .orderBy("reviews.rkey", "desc"); + + let query = db + .selectFrom(latest.as("latest")) + .selectAll() + .where(matchesLabelled(labelled, "latest.uri", "latest.cid")); + if (position) { + query = query.where(after("sortAt", "author", "rkey", position)); + } + + const rows = await query + .orderBy("sortAt", "desc") + .orderBy("author", "desc") + .orderBy("rkey", "desc") + .limit(limit + 1) + .execute(abortable(signal)); + + const page = toPage(rows, limit, ({ sortAt, author, rkey }) => ({ + sortAt, + did: author, + rkey, + })); + return { + ...page, + items: page.items.map((row) => ({ + uri: row.uri, + cid: row.cid, + author: row.author, + rating: row.rating, + text: row.text, + createdAt: row.createdAt, + subject: { uri: row.subjectUri, cid: row.subjectCid }, + })), + }; +} diff --git a/web/src/lib/reviews/records.ts b/web/src/lib/reviews/records.ts new file mode 100644 index 0000000..bb16487 --- /dev/null +++ b/web/src/lib/reviews/records.ts @@ -0,0 +1,97 @@ +import "server-only"; + +import { type Agent, Client, currentDatetimeString } from "@atproto/lex"; +import { AtUri } from "@atproto/syntax"; +import { app } from "@/lexicons"; +import { logger } from "@/lib/logger"; +import { loadViewerReview } from "./queries"; +import type { ReviewDraft, Subject } from "./shared"; + +const { review } = app.attrails; + +export type SaveReviewError = "self-review" | "invalid" | "write-failed"; + +export type SaveReviewResult = + | { ok: true; outcome: "created" | "updated" } + | { ok: false; reason: SaveReviewError }; + +/** + * Saves `draft` to the account's repository as its review of `subject`, + * replacing its newest review of `subject` if it has one. + */ +export async function saveReview( + agent: Agent, + subject: Subject, + draft: ReviewDraft, +): Promise { + const client = new Client(agent); + const did = client.assertDid; + if (new AtUri(subject.uri).hostname === did) { + return { ok: false, reason: "self-review" }; + } + + const record = review.$safeValidate({ + $type: review.$type, + subject: { uri: subject.uri, cid: subject.cid }, + rating: draft.rating, + text: draft.text, + createdAt: currentDatetimeString(), + }); + if (!record.success) { + return { ok: false, reason: "invalid" }; + } + + try { + const latest = await loadViewerReview( + subject, + { did }, + { labelled: "include" }, + ); + if (latest) { + const { rkey } = new AtUri(latest.uri); + await client.put(review, record.value, { rkey }); + return { ok: true, outcome: "updated" }; + } + + await client.create(review, record.value); + return { ok: true, outcome: "created" }; + } catch (error) { + logger.warn({ err: error, did, subject: subject.uri }, "reviews:save"); + return { ok: false, reason: "write-failed" }; + } +} + +export type DeleteReviewError = "not-found" | "write-failed"; + +export type DeleteReviewResult = + { ok: true } | { ok: false; reason: DeleteReviewError }; + +/** + * Deletes the account's newest review of `subject`. Older reviews of it stay + * in the repository. + */ +export async function deleteReview( + agent: Agent, + subject: Pick, +): Promise { + const client = new Client(agent); + const did = client.assertDid; + + try { + const latest = await loadViewerReview( + subject, + { did }, + { labelled: "include" }, + ); + if (!latest) { + return { ok: false, reason: "not-found" }; + } + + const { rkey } = new AtUri(latest.uri); + await client.delete(review, { rkey }); + return { ok: true }; + } catch (error) { + logger.warn({ err: error, did, subject: subject.uri }, "reviews:delete"); + return { ok: false, reason: "write-failed" }; + } +} diff --git a/web/src/lib/reviews/reviews.test.ts b/web/src/lib/reviews/reviews.test.ts new file mode 100644 index 0000000..7845ed9 --- /dev/null +++ b/web/src/lib/reviews/reviews.test.ts @@ -0,0 +1,275 @@ +import { TID } from "@atproto/common-web"; +import { type Agent, cidForRawBytes } from "@atproto/lex"; +import { sql } from "kysely"; +import { describe, expect, test, vi } from "vitest"; +import { getDb } from "@/db/client"; +import { loadLabelledUris } from "@/lib/moderation"; +import { setUpTestDatabase } from "@/test/database"; +import { + deleteReview, + loadAuthorReviews, + loadReviewSummary, + loadReviews, + loadViewerReview, + reviewSchema, + saveReview, + type Subject, +} from "."; + +// `getDb` waits on Next.js to opt out of prerendering, which needs a request. +vi.mock("next/server", () => ({ connection: async () => {} })); + +setUpTestDatabase(); + +const ROUTE_AUTHOR = "did:plc:routeauthor"; +const ALICE = "did:plc:alice"; +const BOB = "did:plc:bob"; + +async function newCid(): Promise { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return (await cidForRawBytes(bytes)).toString(); +} + +async function routeSubject( + rkey: string, + author = ROUTE_AUTHOR, +): Promise { + return { + uri: `at://${author}/app.attrails.route/${rkey}`, + cid: await newCid(), + }; +} + +/** Inserts a review as if ingested from `author`'s repository. */ +async function insertReview( + author: string, + subject: Subject, + { + day, + rating = 3, + labelled = false, + tombstoned = false, + }: { day: number; rating?: number; labelled?: boolean; tombstoned?: boolean }, +) { + const rkey = TID.nextStr(); + const uri = `at://${author}/app.attrails.review/${rkey}`; + const cid = await newCid(); + const at = sql`${`2026-01-01T00:00:00Z`}::timestamptz + make_interval(days => ${day})`; + + const db = await getDb(); + await db + .insertInto("reviews") + .values({ + uri, + cid, + did: author, + rkey, + subject_uri: subject.uri, + subject_cid: subject.cid, + rating, + body: "Lovely views.", + images: JSON.stringify([]), + sort_at: at, + created_at: at, + ingested_at: at, + tombstoned, + }) + .execute(); + + if (labelled) { + await db + .insertInto("labels") + .values({ + uri, + cid, + val: "spam", + cts: sql`now()`, + source: "test", + }) + .execute(); + } + + return { uri, rkey }; +} + +/** An account whose repository records the writes made to it. */ +function fakeAccount(did: string) { + const writes: { method: string; rkey?: string }[] = []; + + const agent: Agent = { + did: did as Agent["did"], + async fetchHandler(path, init) { + const method = path.replace(/^\/xrpc\//, "").split("?")[0]; + const body = + typeof init.body === "string" + ? JSON.parse(init.body) + : JSON.parse(new TextDecoder().decode(init.body as Uint8Array)); + writes.push({ method, rkey: body.rkey }); + + const rkey = body.rkey ?? "created"; + return Response.json({ + uri: `at://${did}/${body.collection}/${rkey}`, + cid: await newCid(), + }); + }, + }; + + return { agent, writes }; +} + +describe("loading reviews", () => { + test("counts each account's newest review, never the subject author's own", async () => { + const subject = await routeSubject("coast"); + await insertReview(ALICE, subject, { day: 1, rating: 1 }); + await insertReview(ALICE, subject, { day: 2, rating: 4 }); + await insertReview(BOB, subject, { day: 3, rating: 5 }); + await insertReview(ROUTE_AUTHOR, subject, { day: 4, rating: 1 }); + await insertReview("did:plc:carol", subject, { + day: 5, + rating: 1, + tombstoned: true, + }); + + const reviews = await loadReviews(subject, { + labelled: "include", + limit: 10, + }); + expect(reviews.map(({ author, rating }) => [author, rating])).toEqual([ + [BOB, 5], + [ALICE, 4], + ]); + + expect(await loadReviewSummary(subject, { labelled: "include" })).toEqual({ + count: 2, + averageRating: 4.5, + }); + }); + + test("filters by moderation labels on the newest review", async () => { + const subject = await routeSubject("hills"); + await insertReview(ALICE, subject, { day: 1 }); + const labelled = await insertReview(ALICE, subject, { + day: 2, + labelled: true, + }); + const clean = await insertReview(BOB, subject, { day: 3 }); + + const load = (filter: "include" | "exclude" | "only") => + loadReviews(subject, { labelled: filter, limit: 10 }); + + // Alice's older, unlabelled review doesn't stand in for her newest one. + expect((await load("exclude")).map(({ uri }) => uri)).toEqual([clean.uri]); + expect((await load("only")).map(({ uri }) => uri)).toEqual([labelled.uri]); + + const all = await load("include"); + expect(all).toHaveLength(2); + expect(await loadLabelledUris(all)).toEqual(new Set([labelled.uri])); + + expect( + await loadViewerReview(subject, { did: ALICE }, { labelled: "exclude" }), + ).toBeNull(); + expect( + await loadViewerReview(subject, null, { labelled: "include" }), + ).toBeNull(); + }); + + test("pages through an account's reviews, newest first", async () => { + const uris: string[] = []; + for (let day = 1; day <= 5; day++) { + uris.unshift( + (await insertReview(ALICE, await routeSubject(`r${day}`), { day })).uri, + ); + } + await insertReview(ALICE, await routeSubject("own", ALICE), { day: 6 }); + + const loaded: string[] = []; + let cursor: string | undefined; + do { + const page = await loadAuthorReviews(ALICE, { + labelled: "include", + cursor, + limit: 2, + }); + loaded.push(...page.items.map(({ uri }) => uri)); + cursor = page.nextCursor ?? undefined; + } while (cursor); + + expect(loaded).toEqual(uris); + }); +}); + +describe("validating reviews", () => { + test("accepts a rating from 1 to 5 with some text", () => { + expect( + reviewSchema.parse({ rating: "4", text: " Great climb. " }), + ).toEqual({ rating: 4, text: "Great climb." }); + }); + + test.each([ + { rating: "", text: "Great climb." }, + { rating: "6", text: "Great climb." }, + { rating: "2.5", text: "Great climb." }, + { rating: "4", text: " " }, + { rating: "4", text: "🏔️".repeat(3001) }, + ])("rejects %o", (input) => { + expect(reviewSchema.safeParse(input).success).toBe(false); + }); + + test("counts characters as people see them", () => { + const text = "🏔️".repeat(3000); + expect(reviewSchema.safeParse({ rating: 4, text }).success).toBe(true); + }); +}); + +describe("writing reviews", () => { + const draft = { rating: 5, text: "Worth the climb." }; + + test("creates a review, then replaces the newest one", async () => { + const subject = await routeSubject("valley"); + const { agent, writes } = fakeAccount(ALICE); + + expect(await saveReview(agent, subject, draft)).toEqual({ + ok: true, + outcome: "created", + }); + + await insertReview(ALICE, subject, { day: 1 }); + const newest = await insertReview(ALICE, subject, { day: 2 }); + expect(await saveReview(agent, subject, draft)).toEqual({ + ok: true, + outcome: "updated", + }); + + expect(writes).toEqual([ + { method: "com.atproto.repo.createRecord", rkey: undefined }, + { method: "com.atproto.repo.putRecord", rkey: newest.rkey }, + ]); + }); + + test("refuses to review the account's own record", async () => { + const { agent, writes } = fakeAccount(ROUTE_AUTHOR); + + expect(await saveReview(agent, await routeSubject("mine"), draft)).toEqual({ + ok: false, + reason: "self-review", + }); + expect(writes).toEqual([]); + }); + + test("deletes the newest review", async () => { + const subject = await routeSubject("ridge"); + const { agent, writes } = fakeAccount(ALICE); + + expect(await deleteReview(agent, subject)).toEqual({ + ok: false, + reason: "not-found", + }); + + await insertReview(ALICE, subject, { day: 1 }); + const newest = await insertReview(ALICE, subject, { day: 2 }); + expect(await deleteReview(agent, subject)).toEqual({ ok: true }); + expect(writes).toEqual([ + { method: "com.atproto.repo.deleteRecord", rkey: newest.rkey }, + ]); + }); +}); diff --git a/web/src/lib/reviews/shared.ts b/web/src/lib/reviews/shared.ts new file mode 100644 index 0000000..a043784 --- /dev/null +++ b/web/src/lib/reviews/shared.ts @@ -0,0 +1,61 @@ +// Safe to import from client components. Server code imports `@/lib/reviews`. + +import { z } from "zod"; + +/** + * The record a review is about, as a strong reference. Reviews are of routes + * for now, but nothing here depends on that. + */ +export type Subject = { uri: string; cid: string }; + +export type Review = { + uri: string; + cid: string; + author: string; + rating: number; + /** `null` for reviews written without text by other apps. */ + text: string | null; + createdAt: Temporal.Instant; +}; + +export type AuthoredReview = Review & { subject: Subject }; + +export type ReviewSummary = { + count: number; + /** `null` if there are no reviews. */ + averageRating: number | null; +}; + +/** `maxGraphemes` of `text` in the `app.attrails.review` lexicon. */ +export const REVIEW_TEXT_MAX_GRAPHEMES = 3000; + +const graphemes = new Intl.Segmenter(); + +function graphemeCount(text: string): number { + return Array.from(graphemes.segment(text)).length; +} + +const RATING_MESSAGE = "Choose a rating from 1 to 5 stars."; +const TEXT_MESSAGE = "Write a few words about it."; + +/** + * A review as the web app accepts it. Stricter than the lexicon, which lets + * reviews leave out text. + */ +export const reviewSchema = z.object({ + rating: z.coerce + .number({ error: RATING_MESSAGE }) + .int(RATING_MESSAGE) + .min(1, RATING_MESSAGE) + .max(5, RATING_MESSAGE), + text: z + .string({ error: TEXT_MESSAGE }) + .trim() + .min(1, TEXT_MESSAGE) + .refine( + (text) => graphemeCount(text) <= REVIEW_TEXT_MAX_GRAPHEMES, + `Keep it to ${REVIEW_TEXT_MAX_GRAPHEMES} characters.`, + ), +}); + +export type ReviewDraft = z.output; diff --git a/web/src/lib/routes.ts b/web/src/lib/routes.ts index f9703eb..21ebaa3 100644 --- a/web/src/lib/routes.ts +++ b/web/src/lib/routes.ts @@ -3,7 +3,12 @@ import "server-only"; import type { LineString, MultiLineString } from "geojson"; import { sql } from "kysely"; import { cache } from "react"; -import { type AttrailsDb, getDb } from "@/db/client"; +import { + abortable, + type AttrailsDb, + CHEAP_QUERY_TIMEOUT_MS, + getDb, +} from "@/db/client"; import type { Location } from "@/lib/location"; import { isLabelled } from "@/lib/moderation"; import type { Page } from "@/lib/pages"; @@ -377,3 +382,28 @@ export async function fetchRouteGpx( return row ?? null; } + +/** + * Loads the names of the routes at `uris`, keyed by URI. Routes that don't + * exist are left out. + */ +export async function loadRouteNames( + uris: string[], + { + signal = AbortSignal.timeout(CHEAP_QUERY_TIMEOUT_MS), + }: { signal?: AbortSignal } = {}, +): Promise> { + if (uris.length === 0) { + return new Map(); + } + + const db = await getDb(); + const rows = await db + .selectFrom("routes") + .select(["uri", "name"]) + .where("uri", "in", uris) + .where("tombstoned", "=", false) + .execute(abortable(signal)); + + return new Map(rows.map(({ uri, name }) => [uri, name])); +} diff --git a/web/src/test/database.ts b/web/src/test/database.ts new file mode 100644 index 0000000..18d371d --- /dev/null +++ b/web/src/test/database.ts @@ -0,0 +1,76 @@ +import { readdir, readFile } from "node:fs/promises"; +import { + PostgreSqlContainer, + type StartedPostgreSqlContainer, +} from "@testcontainers/postgresql"; +import { sql } from "kysely"; +import { + type Migration, + type MigrationProvider, + Migrator, +} from "kysely/migration"; +import { afterAll, beforeAll, beforeEach } from "vitest"; +import { getDb } from "@/db/client"; + +// Keep the Postgres major version in line with production. +const IMAGE = "postgis/postgis:18-3.6"; + +const MIGRATIONS = new URL( + "../../../crates/database/migrations/", + import.meta.url, +); + +// Applies the SQL migrations that sqlx runs in production. +const sqlMigrations: MigrationProvider = { + async getMigrations() { + const files = (await readdir(MIGRATIONS)).filter((file) => + file.endsWith(".sql"), + ); + + const migrations: Record = {}; + for (const file of files) { + const text = await readFile(new URL(file, MIGRATIONS), "utf8"); + migrations[file] = { + up: async (db) => { + await sql.raw(text).execute(db); + }, + }; + } + return migrations; + }, +}; + +/** + * Runs the calling test file against a Postgres container with every + * migration applied, through the app's own `getDb`. Tables are emptied before + * each test. Needs Docker. + */ +export function setUpTestDatabase(): void { + let container: StartedPostgreSqlContainer | undefined; + + beforeAll(async () => { + container = await new PostgreSqlContainer(IMAGE).start(); + process.env.ATTRAILS_POSTGRES_CONNECTION_URI = container.getConnectionUri(); + + const migrator = new Migrator({ + db: await getDb(), + provider: sqlMigrations, + }); + const { error } = await migrator.migrateToLatest(); + if (error) { + throw error; + } + }, 120_000); + + beforeEach(async () => { + const db = await getDb(); + await sql`TRUNCATE reviews, routes, labels`.execute(db); + }); + + afterAll(async () => { + const global = globalThis as { attrailsDb?: { destroy(): Promise } }; + await global.attrailsDb?.destroy(); + global.attrailsDb = undefined; + await container?.stop(); + }); +} diff --git a/web/src/test/server-only.ts b/web/src/test/server-only.ts new file mode 100644 index 0000000..d3f2ec7 --- /dev/null +++ b/web/src/test/server-only.ts @@ -0,0 +1,2 @@ +// Stands in for the `server-only` package in tests. +export {}; diff --git a/web/vitest.config.mts b/web/vitest.config.mts new file mode 100644 index 0000000..66dcb92 --- /dev/null +++ b/web/vitest.config.mts @@ -0,0 +1,14 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + // Throws outside React Server Components, which tests don't run in. + "server-only": fileURLToPath( + new URL("./src/test/server-only.ts", import.meta.url), + ), + }, + }, +});