diff --git a/README.md b/README.md index 57521b1..9135747 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ We are building a web application that helps gymnastics coaches plan their gymna ## Status -The first delivery was a specification. There is no working application yet, but the catalog data has been extracted from the Code of Points and is in [`data/`](data/), and the database schema is in place: the Prisma schema and its migrations create all the tables of the data model in PostgreSQL. The seed script loads `data/` into the database, and the catalog search is written as a backend function. The GraphQL server, which will call it, is the next step. +The first delivery was a specification. The application is not finished: the frontend is still the Vite starter page, but the catalog data has been extracted from the Code of Points and is in [`data/`](data/), and the database schema is in place: the Prisma schema and its migrations create all the tables of the data model in PostgreSQL. The seed script loads `data/` into the database, and a GraphQL server in `backend/` answers the catalog queries and the search (see [GraphQL server](#graphql-server)). Login, the user data and the connection from the frontend are the next steps. The code in `src/` is the Vite starter project with one example test. The tooling is set up: TypeScript, ESLint, Prettier and Vitest. @@ -64,7 +64,7 @@ In the repository now: - A backend package in `backend/` with TypeScript and Prisma, set up as an npm workspace - PostgreSQL 18, installed directly on our own machines (not in Docker), with the Prisma schema and migrations for all the tables of the data model - A seed script that loads the catalog from `data/` into the database -- The catalog search, with Prisma only and cursor pagination. It is not yet reachable through a GraphQL server +- The GraphQL server (Apollo Server) with the catalog queries and the catalog search, with Prisma only and cursor pagination, see [GraphQL server](#graphql-server) - ESLint and Prettier for linting and formatting, for both the frontend and the backend - Vitest for testing - Tailwind CSS, shadcn/ui and Lucide icons for the interface, see [UI components](#ui-components-shadcnui-tailwind-css-and-lucide) @@ -308,6 +308,57 @@ Prisma Studio uses the connection in `backend/.env`, so there is nothing more to To write your own SQL queries, connect with `psql -h localhost -U cop -d cop` (on Windows, use _SQL Shell (psql)_) and enter the password from `backend/.env`. The tables have snake_case names in the database, such as `catalog_entries` and `element_group_id`. `\dt` lists the tables, `\d elements` shows the columns of a table, and `\q` quits. +## GraphQL server + +The server is in `backend/src/server/` (Apollo Server 5 on Express 5). It answers the catalog queries and the search through GraphQL. Login and the user data (gymnasts, routines) are not built yet; those operations answer a clear error, see [The server in docs/API.md](docs/API.md#the-server). + +**Start it.** The database must be migrated and seeded first (see "Database (local setup)" above): + +```bash +npm run backend # restarts when a file changes +npm run start -w backend # starts once +``` + +It listens on port 3001. Open http://localhost:3001/graphql in a browser to try queries in Apollo Sandbox, for example: + +```graphql +{ + searchCatalog(search: "hecht", first: 3) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + description + imageUrl + ... on Element { + value + variants { + id + description + } + } + } + } + } +} +``` + +Other addresses: `/images//.webp` serves the drawings in `data/images/` (on the virtual machine Apache does this), and `/health` answers `{"status":"ok"}`. + +**Settings** (all optional, in `backend/.env`, see `backend/.env.example`): + +| Variable | Default | Used for | +| ---------------- | ------------------------------ | ------------------------------------------------------------------- | +| `PORT` | `3001` | The port of the server | +| `IMAGE_BASE_URL` | `http://localhost:3001/images` | The address the drawings are served from (`imageUrl` in the API) | +| `CORS_ORIGIN` | `http://localhost:5173` | The addresses that may call the API from a browser, comma separated | + +The tests of the server are in `backend/src/server/` and run with `npm test`, like the search tests. They need the seeded database. + ## Catalog search The catalog search is in `backend/src/catalog/search.ts`. It finds elements and vaults by the start of their words, with filters, sorting and cursor pagination, using Prisma only. How it works and why is described in "Search without raw SQL" in [docs/API.md](docs/API.md#search-without-raw-sql). @@ -320,7 +371,7 @@ The tests in `backend/src/catalog/` run with `npm test`. The search tests use yo | Folder | Contents | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search and the draft GraphQL schema | +| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search, the GraphQL server and the GraphQL schema | | `data/` | The catalog data extracted from the Code of Points: JSON files and one image per element | | `docs/` | The specification: user interface plans, data model, example data and AI usage. [docs/EXTRACTION.md](docs/EXTRACTION.md) explains how `data/` was made | | `public/` | Static files served as they are, such as the favicon | diff --git a/backend/.env.example b/backend/.env.example index 2af6655..7886a08 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,5 +6,14 @@ DATABASE_URL="postgresql://wag_app:YOUR_APP_PASSWORD@localhost:5432/wag_catalog" # Migration connection string (wag_migrator - schema owner for migrations & setup) MIGRATION_DATABASE_URL="postgresql://wag_migrator:YOUR_MIGRATOR_PASSWORD@localhost:5432/wag_catalog" -PORT=3001 -SESSION_SECRET="your_session_secret_here" \ No newline at end of file +# The GraphQL server (npm run dev -w backend). All three are optional. +# The port of the server. The backend should be on 3001 on the virtual machine. +# PORT=3001 +# The address the drawings are served from, without a final slash. In development +# the server serves them itself. On the virtual machine it is the Apache address, +# for example http://it2810-XX.idi.ntnu.no/project2/images +# IMAGE_BASE_URL=http://localhost:3001/images +# The addresses that may call the API from a browser, separated by commas. +# CORS_ORIGIN=http://localhost:5173 + +SESSION_SECRET="your_session_secret_here" diff --git a/backend/package.json b/backend/package.json index 18f400d..0aa8ddf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,29 +4,37 @@ "version": "0.0.0", "type": "module", "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts", + "build": "tsc", + "setup": "DATABASE_URL=${MIGRATION_DATABASE_URL:-$DATABASE_URL} prisma migrate deploy && DATABASE_URL=${MIGRATION_DATABASE_URL:-$DATABASE_URL} npm run prisma:seed", "typecheck": "tsc --noEmit", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", "prisma:seed": "prisma db seed", - "prisma:studio": "prisma studio", - "setup": "DATABASE_URL=${MIGRATION_DATABASE_URL:-$DATABASE_URL} prisma migrate deploy && DATABASE_URL=${MIGRATION_DATABASE_URL:-$DATABASE_URL} npm run prisma:seed", - "dev": "tsx watch src/index.ts", - "build": "tsc", - "start": "node dist/index.js" + "prisma:studio": "prisma studio" }, "engines": { "node": ">=24" }, "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", "@types/node": "^24.19.2", - "dotenv": "^17.4.2", "prisma": "^7.10.1", - "tsx": "^4.23.15", "typescript": "~6.0.2" }, "dependencies": { - "@prisma/adapter-pg": "7.10.0", - "@prisma/client": "^7.10.1" + "@apollo/server": "^5.5.1", + "@as-integrations/express5": "^1.1.2", + "@prisma/adapter-pg": "^7.10.1", + "@prisma/client": "^7.10.1", + "cors": "^2.8.6", + "dataloader": "^2.2.3", + "dotenv": "^18.0.8", + "express": "^5.3.0", + "graphql": "^16.14.2", + "tsx": "^4.23.15" } } diff --git a/backend/src/errors.ts b/backend/src/errors.ts index b4c18af..fe22bc4 100644 --- a/backend/src/errors.ts +++ b/backend/src/errors.ts @@ -1,3 +1,7 @@ +// Errors that the GraphQL layer turns into errors with `extensions.code` (see +// "Error handling" in docs/API.md). Resolvers and the search throw these; the +// server maps them in backend/src/server/formatError.ts. + /** * Wrong input from the client. The GraphQL layer turns it into an error with * `extensions.code` "BAD_USER_INPUT" and this message (see "Error handling" @@ -11,3 +15,38 @@ export class BadUserInputError extends Error { this.name = 'BadUserInputError' } } + +/** The operation needs a logged-in coach. Code "UNAUTHENTICATED". */ +export class UnauthenticatedError extends Error { + readonly code = 'UNAUTHENTICATED' + + constructor(message = 'You must be logged in.') { + super(message) + this.name = 'UnauthenticatedError' + } +} + +/** + * The operation is in the schema but not built yet. Code "NOT_IMPLEMENTED". + * This is temporary: it goes away when the login and the user data are done. + */ +export class NotImplementedError extends Error { + readonly code = 'NOT_IMPLEMENTED' + + constructor(operation: string) { + super(`${operation} is not available yet.`) + this.name = 'NotImplementedError' + } +} + +/** The errors above, which the server shows to the client with their code. */ +export type ClientError = + BadUserInputError | UnauthenticatedError | NotImplementedError + +export function isClientError(error: unknown): error is ClientError { + return ( + error instanceof BadUserInputError || + error instanceof UnauthenticatedError || + error instanceof NotImplementedError + ) +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..0a24ea6 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,25 @@ +// Starts the server: `npm run dev -w backend` (restarts on changes) or +// `npm run start -w backend`. The settings are in backend/.env. + +import { createPrismaClient } from './db.js' // reads backend/.env first +import { createApp } from './server/app.js' +import { readConfig } from './server/config.js' + +const config = readConfig() +const prisma = createPrismaClient() +const { app, server } = await createApp({ prisma, config }) + +const listener = app.listen(config.port, () => { + console.log(`GraphQL server on http://localhost:${config.port}/graphql`) + console.log(`Drawings on ${config.imageBaseUrl}`) +}) + +async function shutDown(signal: string) { + console.log(`${signal}: shutting down`) + listener.close() + await server.stop() + await prisma.$disconnect() + process.exit(0) +} +process.on('SIGINT', () => void shutDown('SIGINT')) +process.on('SIGTERM', () => void shutDown('SIGTERM')) diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts new file mode 100644 index 0000000..8a27cae --- /dev/null +++ b/backend/src/server/app.ts @@ -0,0 +1,68 @@ +// The HTTP app: the GraphQL endpoint, the drawings and a health check. It is +// built from a Prisma client and the settings, so the tests can make one +// against any database and call it in the same way as the real server. + +import { ApolloServer } from '@apollo/server' +import { expressMiddleware } from '@as-integrations/express5' +import cors from 'cors' +import express, { type Express } from 'express' +import { fileURLToPath } from 'node:url' +import type { PrismaClient } from '../generated/prisma/client.js' +import type { Config } from './config.js' +import { type Context, createContext } from './context.js' +import { MAX_QUERY_DEPTH, depthLimit } from './depthLimit.js' +import { formatError } from './formatError.js' +import { resolvers } from './resolvers/index.js' +import { loadTypeDefs } from './schema.js' + +/** The drawings (data/images//.webp). Apache serves them on the virtual machine. */ +const IMAGES_DIR = new URL('../../../data/images/', import.meta.url) + +/** The GraphQL server. `executeOperation` works on it without HTTP, for tests. */ +export function createApolloServer(): ApolloServer { + return new ApolloServer({ + typeDefs: loadTypeDefs(), + resolvers, + validationRules: [depthLimit(MAX_QUERY_DEPTH)], + formatError, + includeStacktraceInErrorResponses: false, + }) +} + +export async function createApp(options: { + prisma: PrismaClient + config: Config +}): Promise<{ app: Express; server: ApolloServer }> { + const { prisma, config } = options + const server = createApolloServer() + await server.start() + + const app = express() + app.disable('x-powered-by') + + // For the process manager and the proxy: the server is up. It does not touch the database. + app.get('/health', (_req, res) => { + res.json({ status: 'ok' }) + }) + + // The drawings never change under the same name, so the browser may keep them. + app.use( + '/images', + express.static(fileURLToPath(IMAGES_DIR), { + maxAge: '30d', + immutable: true, + index: false, + }), + ) + + app.use( + '/graphql', + cors({ origin: config.corsOrigins }), + express.json({ limit: '100kb' }), + expressMiddleware(server, { + context: async () => createContext(prisma, config), + }), + ) + + return { app, server } +} diff --git a/backend/src/server/config.test.ts b/backend/src/server/config.test.ts new file mode 100644 index 0000000..5ca9ef5 --- /dev/null +++ b/backend/src/server/config.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_CORS_ORIGIN, DEFAULT_PORT, readConfig } from './config.js' + +describe('readConfig', () => { + it('has defaults for development', () => { + expect(readConfig({})).toEqual({ + port: DEFAULT_PORT, + imageBaseUrl: `http://localhost:${DEFAULT_PORT}/images`, + corsOrigins: [DEFAULT_CORS_ORIGIN], + }) + }) + + it('takes the image address from the port when it is not set', () => { + expect(readConfig({ PORT: '4000' }).imageBaseUrl).toBe( + 'http://localhost:4000/images', + ) + expect(readConfig({ PORT: '4000', IMAGE_BASE_URL: '' }).imageBaseUrl).toBe( + 'http://localhost:4000/images', + ) + }) + + it('removes the final slash of the image address', () => { + expect( + readConfig({ + IMAGE_BASE_URL: 'http://it2810-00.idi.ntnu.no/project2/images///', + }).imageBaseUrl, + ).toBe('http://it2810-00.idi.ntnu.no/project2/images') + }) + + it('reads several allowed origins, separated by commas', () => { + expect( + readConfig({ CORS_ORIGIN: 'http://a.example, http://b.example ,' }) + .corsOrigins, + ).toEqual(['http://a.example', 'http://b.example']) + }) + + it('stops with a clear message when the port is not a port', () => { + for (const port of ['abc', '0', '70000', '30.5', '-1']) { + expect(() => readConfig({ PORT: port })).toThrow( + /PORT must be a whole number from 1 to 65535/, + ) + } + }) +}) diff --git a/backend/src/server/config.ts b/backend/src/server/config.ts new file mode 100644 index 0000000..a1b1854 --- /dev/null +++ b/backend/src/server/config.ts @@ -0,0 +1,43 @@ +// The settings of the server, read from environment variables (backend/.env, +// see backend/.env.example). They are read once at start-up. + +export type Config = { + /** The port the server listens on. */ + port: number + /** + * The address the drawings are served from, without a final slash. The API + * gives `${imageBaseUrl}/${apparatus}/${file}` as `imageUrl`. + */ + imageBaseUrl: string + /** The addresses (origins) that may call the API from a browser. */ + corsOrigins: string[] +} + +export const DEFAULT_PORT = 3001 +export const DEFAULT_CORS_ORIGIN = 'http://localhost:5173' // the Vite dev server + +/** Reads and checks the settings. A wrong value stops the start-up with a clear message. */ +export function readConfig(env: NodeJS.ProcessEnv = process.env): Config { + const rawPort = env['PORT'] + const port = + rawPort === undefined || rawPort === '' ? DEFAULT_PORT : Number(rawPort) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error( + `PORT must be a whole number from 1 to 65535, not "${String(rawPort)}".`, + ) + } + + const rawBase = env['IMAGE_BASE_URL'] + const imageBaseUrl = ( + rawBase === undefined || rawBase === '' + ? `http://localhost:${port}/images` + : rawBase + ).replace(/\/+$/, '') + + const corsOrigins = (env['CORS_ORIGIN'] ?? DEFAULT_CORS_ORIGIN) + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin !== '') + + return { port, imageBaseUrl, corsOrigins } +} diff --git a/backend/src/server/context.ts b/backend/src/server/context.ts new file mode 100644 index 0000000..b49fc76 --- /dev/null +++ b/backend/src/server/context.ts @@ -0,0 +1,15 @@ +// What every resolver can use. A new context is made for each request. + +import type { PrismaClient } from '../generated/prisma/client.js' +import type { Config } from './config.js' +import { type Loaders, createLoaders } from './loaders.js' + +export type Context = { + prisma: PrismaClient + config: Config + loaders: Loaders +} + +export function createContext(prisma: PrismaClient, config: Config): Context { + return { prisma, config, loaders: createLoaders(prisma) } +} diff --git a/backend/src/server/depthLimit.test.ts b/backend/src/server/depthLimit.test.ts new file mode 100644 index 0000000..ff1571f --- /dev/null +++ b/backend/src/server/depthLimit.test.ts @@ -0,0 +1,64 @@ +import { buildSchema, parse, validate } from 'graphql' +import { describe, expect, it } from 'vitest' +import { depthLimit } from './depthLimit.js' + +// A small schema with a cycle, like Apparatus → groups → apparatus in the real one. +const schema = buildSchema(` + type Node { name: String, next: Node } + type Query { node: Node } +`) + +/** The messages from the depth rule for a query. */ +const errorsFor = (query: string, max: number) => + validate(schema, parse(query), [depthLimit(max)]).map((e) => e.message) + +describe('depthLimit', () => { + it('accepts a query at the limit and rejects one level deeper', () => { + // node { next { next { name } } } is 4 levels deep. + const query = '{ node { next { next { name } } } }' + expect(errorsFor(query, 4)).toEqual([]) + expect(errorsFor(query, 3)).toEqual([ + 'The query is 4 levels deep. The limit is 3.', + ]) + }) + + it('counts through inline fragments and named fragments', () => { + const inline = + '{ node { ... on Node { next { ... on Node { next { name } } } } } }' + expect(errorsFor(inline, 4)).toEqual([]) + expect(errorsFor(inline, 3)).toHaveLength(1) + + const named = ` + query { node { ...Level1 } } + fragment Level1 on Node { next { ...Level2 } } + fragment Level2 on Node { next { name } } + ` + expect(errorsFor(named, 4)).toEqual([]) + expect(errorsFor(named, 3)).toHaveLength(1) + }) + + it('does not count the introspection fields', () => { + const introspection = + '{ __schema { types { fields { type { ofType { ofType { name } } } } } } }' + expect(errorsFor(introspection, 2)).toEqual([]) + }) + + it('does not loop on a fragment that uses itself', () => { + const cyclic = ` + query { node { ...A } } + fragment A on Node { next { ...A } } + ` + // Another rule reports the cycle. This rule only has to finish. + expect(() => errorsFor(cyclic, 8)).not.toThrow() + }) + + it('checks every operation of the document', () => { + const query = ` + query Shallow { node { name } } + query Deep { node { next { next { next { name } } } } } + ` + expect(errorsFor(query, 3)).toEqual([ + 'The query is 5 levels deep. The limit is 3.', + ]) + }) +}) diff --git a/backend/src/server/depthLimit.ts b/backend/src/server/depthLimit.ts new file mode 100644 index 0000000..d7fafd7 --- /dev/null +++ b/backend/src/server/depthLimit.ts @@ -0,0 +1,75 @@ +// A limit on how deep a query can be. The schema has cycles (Apparatus → +// groups → apparatus → …), so without a limit a client could ask for a query of +// any depth. The check is a validation rule: a query that is too deep is +// rejected before anything runs, with the code GRAPHQL_VALIDATION_FAILED. +// +// The introspection fields (__schema, __type) are not counted, because the +// tools that read the schema (Apollo Sandbox) ask a deep question on purpose. + +import { + GraphQLError, + Kind, + type ASTVisitor, + type FragmentDefinitionNode, + type SelectionSetNode, + type ValidationContext, +} from 'graphql' + +/** The deepest query the server accepts. `searchCatalog { edges { node { … } } }` is 3 levels before its fields. */ +export const MAX_QUERY_DEPTH = 8 + +function depthOf( + set: SelectionSetNode, + fragments: ReadonlyMap, + visiting: ReadonlySet, +): number { + let deepest = 0 + for (const selection of set.selections) { + if (selection.kind === Kind.FIELD) { + if (selection.name.value.startsWith('__')) continue + const below = selection.selectionSet + ? depthOf(selection.selectionSet, fragments, visiting) + : 0 + deepest = Math.max(deepest, 1 + below) + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + deepest = Math.max( + deepest, + depthOf(selection.selectionSet, fragments, visiting), + ) + } else { + const name = selection.name.value + const fragment = fragments.get(name) + // A fragment that uses itself is reported by another rule; do not loop here. + if (!fragment || visiting.has(name)) continue + deepest = Math.max( + deepest, + depthOf(fragment.selectionSet, fragments, new Set([...visiting, name])), + ) + } + } + return deepest +} + +export function depthLimit(maxDepth: number) { + return (context: ValidationContext): ASTVisitor => { + const fragments = new Map() + for (const definition of context.getDocument().definitions) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + fragments.set(definition.name.value, definition) + } + } + return { + OperationDefinition(operation) { + const depth = depthOf(operation.selectionSet, fragments, new Set()) + if (depth > maxDepth) { + context.reportError( + new GraphQLError( + `The query is ${depth} levels deep. The limit is ${maxDepth}.`, + { nodes: [operation] }, + ), + ) + } + }, + } + } +} diff --git a/backend/src/server/formatError.test.ts b/backend/src/server/formatError.test.ts new file mode 100644 index 0000000..42d8ca7 --- /dev/null +++ b/backend/src/server/formatError.test.ts @@ -0,0 +1,70 @@ +import { GraphQLError } from 'graphql' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + BadUserInputError, + NotImplementedError, + UnauthenticatedError, +} from '../errors.js' +import { formatError } from './formatError.js' + +/** What Apollo hands to formatError for an error thrown in a resolver. */ +const thrownInResolver = (error: Error) => + new GraphQLError(error.message, { + path: ['somewhere'], + originalError: error, + extensions: { code: 'INTERNAL_SERVER_ERROR' }, + }) + +describe('formatError', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it.each([ + [new BadUserInputError('first must be 1–100'), 'BAD_USER_INPUT'], + [new UnauthenticatedError(), 'UNAUTHENTICATED'], + [new NotImplementedError('login'), 'NOT_IMPLEMENTED'], + ])('gives %s its own code and keeps the message', (error, code) => { + const error2 = thrownInResolver(error) + const result = formatError(error2.toJSON(), error2) + expect(result.extensions).toEqual({ code }) + expect(result.message).toBe(error.message) + expect(result.path).toEqual(['somewhere']) + }) + + it('hides the details of an unexpected error and logs the real one', () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const error = thrownInResolver( + new Error('password=secret at db.internal:5432'), + ) + const result = formatError(error.toJSON(), error) + + expect(result.message).toBe('Internal server error') + expect(result.extensions).toEqual({ code: 'INTERNAL_SERVER_ERROR' }) + expect(JSON.stringify(result)).not.toContain('secret') + expect(log).toHaveBeenCalledOnce() + }) + + it('hides an unexpected error even when it carries another code or none', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const withOtherCode = new GraphQLError('db.internal:5432 refused', { + originalError: new Error('db.internal:5432 refused'), + extensions: { code: 'SOMETHING_ELSE' }, + }) + const withoutCode = new GraphQLError('stack at /srv/app.js', { + originalError: new Error('stack at /srv/app.js'), + }) + for (const error of [withOtherCode, withoutCode]) { + const result = formatError(error.toJSON(), error) + expect(result.message).toBe('Internal server error') + expect(result.extensions).toEqual({ code: 'INTERNAL_SERVER_ERROR' }) + } + }) + + it('leaves the errors from GraphQL itself as they are', () => { + const error = new GraphQLError('The query is 9 levels deep.', { + extensions: { code: 'GRAPHQL_VALIDATION_FAILED' }, + }) + expect(formatError(error.toJSON(), error)).toEqual(error.toJSON()) + }) +}) diff --git a/backend/src/server/formatError.ts b/backend/src/server/formatError.ts new file mode 100644 index 0000000..0be3fde --- /dev/null +++ b/backend/src/server/formatError.ts @@ -0,0 +1,48 @@ +// How errors look to the client (see "Error handling" in docs/API.md). +// - The errors we throw on purpose (BadUserInputError and the others in +// src/errors.ts) get their code in `extensions.code`. +// - Errors from GraphQL itself (an invalid query, a query that is too deep) +// keep their own code, for example GRAPHQL_VALIDATION_FAILED. +// - Anything else is a bug or a failure on our side: the client gets a +// general message and no details, and the real error goes to the log. + +import type { GraphQLFormattedError } from 'graphql' +import { isClientError } from '../errors.js' + +/** + * The error that a resolver threw. GraphQL wraps it in its own error and keeps + * it as `originalError`. This reads that field directly and does not use + * `instanceof GraphQLError` (as Apollo's `unwrapResolverError` does), because + * that check fails when two copies of the `graphql` package are loaded. + */ +function unwrapResolverError(error: unknown): unknown { + const original = (error as { originalError?: unknown } | null)?.originalError + return original ?? error +} + +export function formatError( + formatted: GraphQLFormattedError, + error: unknown, +): GraphQLFormattedError { + const original = unwrapResolverError(error) + if (isClientError(original)) { + return { ...formatted, extensions: { code: original.code } } + } + // An error that wraps something we did not throw on purpose is always masked, + // whatever code it carries. A bare error without a code is masked too. + const wrapsUnknownError = original !== error + const code = formatted.extensions?.['code'] + if ( + wrapsUnknownError || + code === undefined || + code === 'INTERNAL_SERVER_ERROR' + ) { + console.error('Internal server error:', original) + return { + message: 'Internal server error', + extensions: { code: 'INTERNAL_SERVER_ERROR' }, + ...(formatted.path ? { path: formatted.path } : {}), + } + } + return formatted +} diff --git a/backend/src/server/items.ts b/backend/src/server/items.ts new file mode 100644 index 0000000..fdfd75e --- /dev/null +++ b/backend/src/server/items.ts @@ -0,0 +1,28 @@ +// The catalog items as the resolvers see them. The database has elements and +// vaults in two tables. GraphQL has one interface, CatalogItem, with the types +// Element and Vault. Each row gets a `__typename`, which is how the interface is +// resolved to one of the two types. + +import type { Element, Vault } from '../generated/prisma/client.js' + +export type ElementItem = Element & { __typename: 'Element' } +export type VaultItem = Vault & { __typename: 'Vault' } +export type Item = ElementItem | VaultItem + +/** + * Vault ids and the ids of vault variant groups start with "VT-" (see + * "Vault is its own entity" in docs/DATA_MODEL.md). + */ +export const isVaultId = (id: string): boolean => id.startsWith('VT-') + +/** The apparatus an item belongs to: the first part of its id ("UB" in "UB-2.305-a"). */ +export const apparatusOfId = (id: string): string => id.split('-')[0] ?? id + +export const asElement = (row: Element): ElementItem => ({ + ...row, + __typename: 'Element', +}) +export const asVault = (row: Vault): VaultItem => ({ + ...row, + __typename: 'Vault', +}) diff --git a/backend/src/server/loaders.ts b/backend/src/server/loaders.ts new file mode 100644 index 0000000..3c0680f --- /dev/null +++ b/backend/src/server/loaders.ts @@ -0,0 +1,70 @@ +// DataLoaders: they collect the lookups that the resolvers make during one +// request and send one query for each kind, instead of one query for each +// search result. A new set is made for every request, so nothing is shared +// between requests (see "N+1 queries" in docs/API.md). + +import DataLoader from 'dataloader' +import type { Apparatus, PrismaClient } from '../generated/prisma/client.js' +import { type Item, asElement, asVault, isVaultId } from './items.js' + +export function createLoaders(prisma: PrismaClient) { + /** An element or vault by its id, also when it is removed. `null` when it does not exist. */ + const itemById = new DataLoader(async (ids) => { + const elementIds = ids.filter((id) => !isVaultId(id)) + const vaultIds = ids.filter(isVaultId) + const [elements, vaults] = await Promise.all([ + elementIds.length > 0 + ? prisma.element.findMany({ where: { id: { in: elementIds } } }) + : [], + vaultIds.length > 0 + ? prisma.vault.findMany({ where: { id: { in: vaultIds } } }) + : [], + ]) + const byId = new Map() + for (const row of elements) byId.set(row.id, asElement(row)) + for (const row of vaults) byId.set(row.id, asVault(row)) + return ids.map((id) => byId.get(id) ?? null) + }) + + /** The members of a variant group that are still in the Code of Points, in id order. */ + const variantsOfGroup = new DataLoader(async (groupIds) => { + const elementGroups = groupIds.filter((id) => !isVaultId(id)) + const vaultGroups = groupIds.filter(isVaultId) + const [elements, vaults] = await Promise.all([ + elementGroups.length > 0 + ? prisma.element.findMany({ + where: { variantGroupId: { in: elementGroups }, removedAt: null }, + orderBy: { id: 'asc' }, + }) + : [], + vaultGroups.length > 0 + ? prisma.vault.findMany({ + where: { variantGroupId: { in: vaultGroups }, removedAt: null }, + orderBy: { id: 'asc' }, + }) + : [], + ]) + const byGroup = new Map() + const add = (groupId: string | null, item: Item) => { + if (groupId === null) return + byGroup.set(groupId, [...(byGroup.get(groupId) ?? []), item]) + } + for (const row of elements) add(row.variantGroupId, asElement(row)) + for (const row of vaults) add(row.variantGroupId, asVault(row)) + return groupIds.map((id) => byGroup.get(id) ?? []) + }) + + const apparatusById = new DataLoader( + async (ids) => { + const rows = await prisma.apparatus.findMany({ + where: { id: { in: [...ids] } }, + }) + const byId = new Map(rows.map((row) => [row.id, row])) + return ids.map((id) => byId.get(id) ?? null) + }, + ) + + return { itemById, variantsOfGroup, apparatusById } +} + +export type Loaders = ReturnType diff --git a/backend/src/server/resolvers/catalog.ts b/backend/src/server/resolvers/catalog.ts new file mode 100644 index 0000000..a822165 --- /dev/null +++ b/backend/src/server/resolvers/catalog.ts @@ -0,0 +1,150 @@ +// The resolvers of the catalog: apparatus, groups, elements, vaults and the +// search. They are thin: the search itself is in backend/src/catalog/search.ts, +// and the loaders collect the database lookups (see loaders.ts). + +import type { GraphQLResolveInfo } from 'graphql' +import type { Apparatus, ElementGroup } from '../../generated/prisma/client.js' +import { + type CatalogSearchArgs, + listElementGroups, + searchCatalog, +} from '../../catalog/search.js' +import { catalogStructure } from '../../catalog/structure.js' +import type { Context } from '../context.js' +import { + type ElementItem, + type Item, + type VaultItem, + apparatusOfId, +} from '../items.js' +import { selectsField } from '../selectsField.js' + +type SearchArgs = Omit + +/** The fields that Element and Vault share (the CatalogItem interface). */ +const itemFields = { + /** The address of the drawing: the base address, the apparatus folder and the file name. */ + imageUrl: (item: Item, _args: unknown, ctx: Context): string => + `${ctx.config.imageBaseUrl}/${apparatusOfId(item.id)}/${item.image}`, + + elementGroup: async ( + item: Item, + _args: unknown, + ctx: Context, + ): Promise => { + const group = (await catalogStructure(ctx.prisma)).groupsById.get( + item.elementGroupId, + ) + if (!group) + throw new Error( + `The element group ${item.elementGroupId} does not exist.`, + ) + return group + }, + + removed: (item: Item): boolean => item.removedAt !== null, + + /** The other ways of performing the item that are still in the Code of Points. */ + variants: async ( + item: Item, + _args: unknown, + ctx: Context, + ): Promise => { + if (item.variantGroupId === null) return [] + const members = await ctx.loaders.variantsOfGroup.load(item.variantGroupId) + return members.filter((member) => member.id !== item.id) + }, +} + +export const catalogResolvers = { + Query: { + apparatus: ( + _parent: unknown, + _args: unknown, + ctx: Context, + ): Promise => + ctx.prisma.apparatus.findMany({ orderBy: { position: 'asc' } }), + + elementGroup: async ( + _parent: unknown, + args: { id: string }, + ctx: Context, + ): Promise => + (await catalogStructure(ctx.prisma)).groupsById.get(args.id) ?? null, + + element: async (_parent: unknown, args: { id: string }, ctx: Context) => { + const item = await ctx.loaders.itemById.load(args.id) + return item?.__typename === 'Element' ? item : null + }, + + vault: async (_parent: unknown, args: { id: string }, ctx: Context) => { + const item = await ctx.loaders.itemById.load(args.id) + return item?.__typename === 'Vault' ? item : null + }, + + catalogItem: (_parent: unknown, args: { id: string }, ctx: Context) => + ctx.loaders.itemById.load(args.id), + + /** + * The search returns one catalog entry per code. The element or vault + * behind each entry is loaded in one batch, so a page costs a fixed number + * of queries. The total count is only calculated when the query asks for it. + */ + searchCatalog: async ( + _parent: unknown, + args: SearchArgs, + ctx: Context, + info: GraphQLResolveInfo, + ) => { + const result = await searchCatalog(ctx.prisma, { + ...args, + withTotalCount: selectsField(info, 'totalCount'), + }) + const items = await ctx.loaders.itemById.loadMany( + result.edges.map((edge) => edge.node.id), + ) + const edges = result.edges.map((edge, index) => { + const item = items[index] + if (!item || item instanceof Error) { + throw new Error( + `The catalog entry ${edge.node.id} has no element or vault. Run the seed again.`, + ) + } + return { cursor: edge.cursor, node: item } + }) + return { edges, pageInfo: result.pageInfo, totalCount: result.totalCount } + }, + }, + + Apparatus: { + groups: ( + apparatus: Apparatus, + _args: unknown, + ctx: Context, + ): Promise => listElementGroups(ctx.prisma, apparatus.id), + }, + + ElementGroup: { + apparatus: ( + group: ElementGroup, + _args: unknown, + ctx: Context, + ): Promise => + ctx.loaders.apparatusById.load(group.apparatusId), + }, + + /** Tells GraphQL which type an item is: the rows carry a `__typename`. */ + CatalogItem: { + __resolveType: (item: Item): 'Element' | 'Vault' => item.__typename, + }, + + Element: { + ...itemFields, + value: (item: ElementItem): number => item.value.toNumber(), + }, + + Vault: { + ...itemFields, + dValue: (item: VaultItem): number => item.dValue.toNumber(), + }, +} diff --git a/backend/src/server/resolvers/index.ts b/backend/src/server/resolvers/index.ts new file mode 100644 index 0000000..d8b6e47 --- /dev/null +++ b/backend/src/server/resolvers/index.ts @@ -0,0 +1,9 @@ +import { catalogResolvers } from './catalog.js' +import { userDataResolvers } from './userData.js' + +/** All the resolvers, merged by type. Add a file per area and merge it here. */ +export const resolvers = { + ...catalogResolvers, + ...userDataResolvers, + Query: { ...catalogResolvers.Query, ...userDataResolvers.Query }, +} diff --git a/backend/src/server/resolvers/userData.ts b/backend/src/server/resolvers/userData.ts new file mode 100644 index 0000000..a11e20a --- /dev/null +++ b/backend/src/server/resolvers/userData.ts @@ -0,0 +1,43 @@ +// The user data (coaches, gymnasts, routines) and the login are not built yet. +// Until they are, every operation answers with a clear error, so the schema can +// be used as it is and nothing returns a confusing null: +// - nobody can log in, so the protected operations answer UNAUTHENTICATED, +// which is also what they will answer to a visitor who is not logged in later +// - login and the calculator answer NOT_IMPLEMENTED (temporary) +// Replace these with the real resolvers in the issues for the login and the +// user data. + +import { NotImplementedError, UnauthenticatedError } from '../../errors.js' + +const loginRequired = (): never => { + throw new UnauthenticatedError( + 'You must be logged in. Login is not available yet.', + ) +} + +const notImplemented = (operation: string) => (): never => { + throw new NotImplementedError(operation) +} + +export const userDataResolvers = { + Query: { + /** Nobody can log in yet, so there is never a logged-in coach. */ + me: (): null => null, + gymnast: loginRequired, + routine: loginRequired, + calculateDScore: notImplemented('calculateDScore'), + }, + + Mutation: { + login: notImplemented('login'), + createGymnast: loginRequired, + renameGymnast: loginRequired, + deleteGymnast: loginRequired, + addGymnastElement: loginRequired, + removeGymnastElement: loginRequired, + createRoutine: loginRequired, + renameRoutine: loginRequired, + deleteRoutine: loginRequired, + setRoutineItems: loginRequired, + }, +} diff --git a/backend/src/server/schema.ts b/backend/src/server/schema.ts new file mode 100644 index 0000000..d17830f --- /dev/null +++ b/backend/src/server/schema.ts @@ -0,0 +1,8 @@ +// The schema is written first, in backend/src/schema.graphql, and the server +// reads it from there (schema first, see "Server and types" in docs/API.md). + +import { readFileSync } from 'node:fs' + +export function loadTypeDefs(): string { + return readFileSync(new URL('../schema.graphql', import.meta.url), 'utf8') +} diff --git a/backend/src/server/selectsField.test.ts b/backend/src/server/selectsField.test.ts new file mode 100644 index 0000000..7dbaf4e --- /dev/null +++ b/backend/src/server/selectsField.test.ts @@ -0,0 +1,89 @@ +import { + buildSchema, + execute, + graphql, + parse, + type GraphQLResolveInfo, +} from 'graphql' +import { describe, expect, it } from 'vitest' +import { selectsField } from './selectsField.js' + +const schema = buildSchema(` + type Page { total: Int, items: [String] } + type Query { page: Page } +`) + +/** Runs a query and tells whether the resolver of `page` sees `total` in it. */ +async function sees( + source: string, + variables?: Record, +): Promise { + let seen: boolean | undefined + await graphql({ + schema, + source, + variableValues: variables, + rootValue: { + page: (_args: unknown, _ctx: unknown, info: GraphQLResolveInfo) => { + seen = selectsField(info, 'total') + return { total: 1, items: [] } + }, + }, + }) + if (seen === undefined) throw new Error('The resolver did not run.') + return seen +} + +describe('selectsField', () => { + it('sees a field that is asked for, also with an alias', async () => { + expect(await sees('{ page { total } }')).toBe(true) + expect(await sees('{ page { count: total } }')).toBe(true) + }) + + it('does not see a field that is not asked for', async () => { + expect(await sees('{ page { items } }')).toBe(false) + }) + + it('sees it through inline fragments and named fragments', async () => { + expect(await sees('{ page { ... on Page { total } } }')).toBe(true) + expect(await sees('{ page { ...P } } fragment P on Page { total }')).toBe( + true, + ) + expect(await sees('{ page { ...P } } fragment P on Page { items }')).toBe( + false, + ) + }) + + it('does not loop on a fragment that uses itself', async () => { + // Validation rejects such a query before it runs, so run it without validation. + let seen: boolean | undefined + await execute({ + schema, + document: parse('{ page { ...P } } fragment P on Page { ...P }'), + rootValue: { + page: (_args: unknown, _ctx: unknown, info: GraphQLResolveInfo) => { + seen = selectsField(info, 'total') + return { total: 1 } + }, + }, + }) + expect(seen).toBe(false) + }) + + it('follows @include and @skip, with a variable or a value', async () => { + const query = 'query($on: Boolean!) { page { total @include(if: $on) } }' + expect(await sees(query, { on: true })).toBe(true) + expect(await sees(query, { on: false })).toBe(false) + + expect(await sees('{ page { total @include(if: false) } }')).toBe(false) + expect(await sees('{ page { total @skip(if: true) } }')).toBe(false) + expect(await sees('{ page { total @skip(if: false) } }')).toBe(true) + }) + + it('follows @include on a fragment', async () => { + const query = + 'query($on: Boolean!) { page { ...P @include(if: $on) } } fragment P on Page { total }' + expect(await sees(query, { on: true })).toBe(true) + expect(await sees(query, { on: false })).toBe(false) + }) +}) diff --git a/backend/src/server/selectsField.ts b/backend/src/server/selectsField.ts new file mode 100644 index 0000000..17ce638 --- /dev/null +++ b/backend/src/server/selectsField.ts @@ -0,0 +1,63 @@ +// Whether a query asks for a field directly under the field being resolved, +// also through fragments, and not when `@skip` or `@include` leaves it out. +// The search uses it so that the total count, which reads every match, is only +// calculated when the client asks for it. A client asks only on the first page +// with `totalCount @include(if: $firstPage)`. + +import { + GraphQLIncludeDirective, + GraphQLSkipDirective, + Kind, + getDirectiveValues, + type FieldNode, + type FragmentSpreadNode, + type GraphQLResolveInfo, + type InlineFragmentNode, + type SelectionSetNode, +} from 'graphql' + +/** False when `@skip(if: true)` or `@include(if: false)` leaves the node out. */ +function isIncluded( + node: FieldNode | FragmentSpreadNode | InlineFragmentNode, + info: GraphQLResolveInfo, +): boolean { + if ( + getDirectiveValues(GraphQLSkipDirective, node, info.variableValues)?.[ + 'if' + ] === true + ) { + return false + } + if ( + getDirectiveValues(GraphQLIncludeDirective, node, info.variableValues)?.[ + 'if' + ] === false + ) { + return false + } + return true +} + +export function selectsField( + info: GraphQLResolveInfo, + fieldName: string, +): boolean { + const visit = ( + set: SelectionSetNode | undefined, + seenFragments: Set, + ): boolean => { + if (!set) return false + return set.selections.some((selection) => { + if (!isIncluded(selection, info)) return false + if (selection.kind === Kind.FIELD) + return selection.name.value === fieldName + if (selection.kind === Kind.INLINE_FRAGMENT) + return visit(selection.selectionSet, seenFragments) + const name = selection.name.value + if (seenFragments.has(name)) return false + seenFragments.add(name) + return visit(info.fragments[name]?.selectionSet, seenFragments) + }) + } + return info.fieldNodes.some((node) => visit(node.selectionSet, new Set())) +} diff --git a/backend/src/server/server.test.ts b/backend/src/server/server.test.ts new file mode 100644 index 0000000..4cce540 --- /dev/null +++ b/backend/src/server/server.test.ts @@ -0,0 +1,670 @@ +// Tests for the GraphQL server. They run against the local database from +// backend/.env and need the seeded catalog (run `npm run db:seed` first). They +// only read: no catalog rows or user data are changed. Without DATABASE_URL +// the tests are skipped. + +import type { ApolloServer } from '@apollo/server' +import { PrismaPg } from '@prisma/adapter-pg' +import { buildSchema } from 'graphql' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { searchCatalog } from '../catalog/search.js' +import { createPrismaClient } from '../db.js' +import { PrismaClient as LoggingClient } from '../generated/prisma/client.js' +import type { PrismaClient } from '../generated/prisma/client.js' +import { createApp, createApolloServer } from './app.js' +import type { Config } from './config.js' +import { type Context, createContext } from './context.js' +import { MAX_QUERY_DEPTH } from './depthLimit.js' +import { userDataResolvers } from './resolvers/userData.js' +import { loadTypeDefs } from './schema.js' + +const hasDatabase = Boolean(process.env['DATABASE_URL']) + +const config: Config = { + port: 3001, + imageBaseUrl: 'http://images.test/images', + corsOrigins: ['http://allowed.test'], +} + +type GqlError = { message: string; extensions?: Record } +type Result = { + data: T | null | undefined + errors: readonly GqlError[] | undefined +} + +describe.runIf(hasDatabase)('the GraphQL server on the seeded catalog', () => { + let prisma: PrismaClient + let server: ApolloServer + let rowCounts: number[] + + const counts = () => + Promise.all([ + prisma.catalogEntry.count(), + prisma.catalogWord.count(), + prisma.element.count(), + prisma.vault.count(), + prisma.coach.count(), + prisma.gymnast.count(), + prisma.routine.count(), + ]) + + /** Runs a query on the server without HTTP. */ + async function run( + query: string, + variables?: Record, + client: PrismaClient = prisma, + ): Promise> { + const response = await server.executeOperation( + { query, variables }, + { contextValue: createContext(client, config) }, + ) + if (response.body.kind !== 'single') + throw new Error('Expected a single result.') + const { data, errors } = response.body.singleResult + return { data: data as T | null | undefined, errors } + } + + const codeOf = (result: Result) => + result.errors?.[0]?.extensions?.['code'] + + beforeAll(async () => { + prisma = createPrismaClient() + if ((await prisma.catalogEntry.count()) === 0) { + throw new Error('The catalog is empty. Run `npm run db:seed` first.') + } + server = createApolloServer() + await server.start() + rowCounts = await counts() + }) + + afterAll(async () => { + // The server only reads: nothing in the catalog or the user data changed. + expect(await counts()).toEqual(rowCounts) + await server.stop() + await prisma.$disconnect() + }) + + describe('the catalog', () => { + it('lists the apparatus in the order of the Code of Points, with their groups', async () => { + const { data, errors } = await run<{ + apparatus: { + id: string + groups: { id: string; number: number; apparatus: { id: string } }[] + }[] + }>('{ apparatus { id groups { id number apparatus { id } } } }') + + expect(errors).toBeUndefined() + const expected = await prisma.apparatus.findMany({ + orderBy: { position: 'asc' }, + }) + expect(data!.apparatus.map((a) => a.id)).toEqual( + expected.map((a) => a.id), + ) + for (const apparatus of data!.apparatus) { + expect(apparatus.groups.length).toBeGreaterThan(0) + const numbers = apparatus.groups.map((g) => g.number) + expect(numbers).toEqual([...numbers].sort((a, b) => a - b)) + expect( + apparatus.groups.every((g) => g.apparatus.id === apparatus.id), + ).toBe(true) + } + }) + + it('gives an element with its value, drawing, group and removed flag', async () => { + const row = await prisma.element.findFirstOrThrow({ + orderBy: { id: 'asc' }, + }) + const { data, errors } = await run<{ + element: { + id: string + code: string + description: string + name: string | null + imageUrl: string + removed: boolean + value: number + elementGroup: { id: string; name: string } + } + }>( + `query($id: ID!) { element(id: $id) { id code description name imageUrl removed value + elementGroup { id name } } }`, + { id: row.id }, + ) + + expect(errors).toBeUndefined() + expect(data!.element).toMatchObject({ + id: row.id, + code: row.code, + description: row.description, + name: row.name, + removed: false, + value: row.value.toNumber(), + elementGroup: { id: row.elementGroupId }, + imageUrl: `http://images.test/images/${row.id.split('-')[0]}/${row.image}`, + }) + expect(typeof data!.element.value).toBe('number') + }) + + it('gives a vault with its D-value', async () => { + const row = await prisma.vault.findFirstOrThrow({ + orderBy: { id: 'asc' }, + }) + const { data, errors } = await run<{ + vault: { id: string; dValue: number; imageUrl: string } + }>('query($id: ID!) { vault(id: $id) { id dValue imageUrl } }', { + id: row.id, + }) + + expect(errors).toBeUndefined() + expect(data!.vault.dValue).toBe(row.dValue.toNumber()) + expect(data!.vault.imageUrl).toBe( + `http://images.test/images/VT/${row.image}`, + ) + }) + + it('resolves catalogItem to Element or Vault, and does not mix the two', async () => { + const element = await prisma.element.findFirstOrThrow() + const vault = await prisma.vault.findFirstOrThrow() + const { data } = await run<{ + a: { __typename: string; id: string } + b: { __typename: string; id: string } + elementWithVaultId: unknown + vaultWithElementId: unknown + unknown: unknown + odd: unknown + }>( + `query($e: ID!, $v: ID!) { + a: catalogItem(id: $e) { __typename id } + b: catalogItem(id: $v) { __typename id } + elementWithVaultId: element(id: $v) { id } + vaultWithElementId: vault(id: $e) { id } + unknown: catalogItem(id: "XX-9.9-a") { id } + odd: catalogItem(id: "'; DROP TABLE elements;--") { id } + }`, + { e: element.id, v: vault.id }, + ) + + expect(data!.a).toEqual({ __typename: 'Element', id: element.id }) + expect(data!.b).toEqual({ __typename: 'Vault', id: vault.id }) + expect(data!.elementWithVaultId).toBeNull() + expect(data!.vaultWithElementId).toBeNull() + expect(data!.unknown).toBeNull() + expect(data!.odd).toBeNull() + }) + + it('lists the other variants of a code, and none for a code with one variant', async () => { + const group = await prisma.variantGroup.findFirstOrThrow({ + where: { elements: { some: {} } }, + include: { elements: { orderBy: { id: 'asc' } } }, + }) + const [first, second] = group.elements + const single = await prisma.element.findFirstOrThrow({ + where: { variantGroupId: null }, + }) + + const { data, errors } = await run<{ + first: { variants: { id: string }[] } + second: { variants: { id: string }[] } + single: { variants: { id: string }[] } + }>( + `query($a: ID!, $b: ID!, $c: ID!) { + first: element(id: $a) { variants { id } } + second: element(id: $b) { variants { id } } + single: element(id: $c) { variants { id } } + }`, + { a: first!.id, b: second!.id, c: single.id }, + ) + + expect(errors).toBeUndefined() + const others = (id: string) => + group.elements + .filter((e) => e.id !== id && e.removedAt === null) + .map((e) => e.id) + expect(data!.first.variants.map((v) => v.id)).toEqual(others(first!.id)) + expect(data!.second.variants.map((v) => v.id)).toEqual(others(second!.id)) + expect(data!.single.variants).toEqual([]) + }) + + it('finds an element group by id and gives its apparatus', async () => { + const { data } = await run<{ + group: { id: string; apparatus: { id: string } } + none: unknown + }>( + '{ group: elementGroup(id: "UB-1") { id apparatus { id } } none: elementGroup(id: "nope") { id } }', + ) + expect(data!.group).toEqual({ id: 'UB-1', apparatus: { id: 'UB' } }) + expect(data!.none).toBeNull() + }) + }) + + describe('searchCatalog', () => { + const SEARCH = `query($search: String, $filter: CatalogFilter, $sort: CatalogSort, $first: Int!, $after: String) { + searchCatalog(search: $search, filter: $filter, sort: $sort, first: $first, after: $after) { + totalCount + pageInfo { hasNextPage endCursor } + edges { cursor node { __typename id code } } + } + }` + type Connection = { + totalCount: number + pageInfo: { hasNextPage: boolean; endCursor: string | null } + edges: { + cursor: string + node: { __typename: string; id: string; code: string } + }[] + } + + it('gives the same page, cursors and count as the search function', async () => { + const variables = { + search: 'hecht', + sort: { field: 'NAME', direction: 'DESC' }, + first: 5, + } + const { data, errors } = await run<{ searchCatalog: Connection }>( + SEARCH, + variables, + ) + const direct = await searchCatalog(prisma, { + search: 'hecht', + sort: { field: 'NAME', direction: 'DESC' }, + first: 5, + withTotalCount: true, + }) + + expect(errors).toBeUndefined() + expect(data!.searchCatalog.edges.map((e) => e.node.id)).toEqual( + direct.edges.map((e) => e.node.id), + ) + expect(data!.searchCatalog.edges.map((e) => e.cursor)).toEqual( + direct.edges.map((e) => e.cursor), + ) + expect(data!.searchCatalog.pageInfo).toEqual(direct.pageInfo) + expect(data!.searchCatalog.totalCount).toBe(direct.totalCount) + expect( + data!.searchCatalog.edges.every((e) => e.node.__typename === 'Element'), + ).toBe(true) + }) + + it('finds both elements and vaults, each as its own type', async () => { + const { data } = await run<{ searchCatalog: Connection }>(SEARCH, { + first: 100, + }) + const types = new Set( + data!.searchCatalog.edges.map((e) => e.node.__typename), + ) + expect(types).toEqual(new Set(['Element', 'Vault'])) + }) + + it('follows the cursors to the end without skipping or repeating a result', async () => { + const ids: string[] = [] + let after: string | null = null + let pages = 0 + do { + const { data, errors }: Result<{ searchCatalog: Connection }> = + await run(SEARCH, { + search: 'turn', + first: 7, + after, + }) + expect(errors).toBeUndefined() + ids.push(...data!.searchCatalog.edges.map((e) => e.node.id)) + after = data!.searchCatalog.pageInfo.hasNextPage + ? data!.searchCatalog.pageInfo.endCursor + : null + pages++ + } while (after !== null && pages < 200) + + const all = await searchCatalog(prisma, { + search: 'turn', + first: 100, + withTotalCount: true, + }) + expect(all.totalCount).toBeGreaterThan(14) // more than two pages + expect(new Set(ids).size).toBe(ids.length) + expect(ids).toHaveLength(all.totalCount!) + }) + + it('filters and sorts over the whole catalog', async () => { + const { data, errors } = await run<{ searchCatalog: Connection }>( + SEARCH, + { + filter: { apparatusId: 'BB', difficultyValues: [0.4, 0.5] }, + sort: { field: 'DIFFICULTY', direction: 'DESC' }, + first: 100, + }, + ) + expect(errors).toBeUndefined() + const direct = await searchCatalog(prisma, { + filter: { apparatusId: 'BB', difficultyValues: [0.4, 0.5] }, + sort: { field: 'DIFFICULTY', direction: 'DESC' }, + first: 100, + }) + expect(data!.searchCatalog.edges.map((e) => e.node.id)).toEqual( + direct.edges.map((e) => e.node.id), + ) + expect(data!.searchCatalog.edges.length).toBeGreaterThan(0) + }) + }) + + describe('database calls', () => { + const PAGE = `query($first: Int!, $withCount: Boolean!) { + searchCatalog(search: "turn", first: $first) { + totalCount @include(if: $withCount) + edges { node { + id description imageUrl removed + ... on Element { value variants { id } elementGroup { name apparatus { name } } } + ... on Vault { dValue variants { id } elementGroup { name apparatus { name } } } + } } + } + }` + + /** A client that records the SQL it sends. */ + function loggingClient() { + const sql: string[] = [] + const client = new LoggingClient({ + adapter: new PrismaPg({ + connectionString: process.env['DATABASE_URL']!, + }), + log: [{ emit: 'event', level: 'query' }], + }) + client.$on('query', (e) => sql.push(e.query)) + return { client, sql } + } + + it('counts the matches only when the query asks for totalCount', async () => { + const { client, sql } = loggingClient() + try { + await run(PAGE, { first: 5, withCount: false }, client) // also fills the structure cache + sql.length = 0 + await run(PAGE, { first: 5, withCount: false }, client) + expect(sql.filter((q) => /COUNT\(\*\)/i.test(q))).toHaveLength(0) + + sql.length = 0 + await run(PAGE, { first: 5, withCount: true }, client) + expect(sql.filter((q) => /COUNT\(\*\)/i.test(q))).toHaveLength(1) + } finally { + await client.$disconnect() + } + }) + + it('sends a fixed number of queries, not one for each result', async () => { + const { client, sql } = loggingClient() + try { + await run(PAGE, { first: 5, withCount: false }, client) // fills the structure cache + const queriesFor = async (first: number) => { + sql.length = 0 + const { data, errors } = await run<{ + searchCatalog: { edges: unknown[] } + }>(PAGE, { first, withCount: false }, client) + expect(errors).toBeUndefined() + expect(data!.searchCatalog.edges).toHaveLength(first) + return sql.length + } + // The loaders send one batch for each kind: the elements and their + // variants, the vaults and their variants, and the apparatus. The exact + // number varies by one or two, because the batches are made in the + // order the resolvers finish. It must stay small however many results + // there are: one query for each of 100 results would be over 100. + const counts = [ + await queriesFor(5), + await queriesFor(50), + await queriesFor(100), + ] + + expect(Math.max(...counts)).toBeLessThanOrEqual(10) + } finally { + await client.$disconnect() + } + }) + }) + + describe('errors', () => { + it.each([ + [ + 'first is 0', + '{ searchCatalog(first: 0) { totalCount } }', + /first must be/, + ], + [ + 'first is above 100', + '{ searchCatalog(first: 101) { totalCount } }', + /first must be/, + ], + [ + '9 search words', + '{ searchCatalog(search: "a b c d e f g h i") { totalCount } }', + /at most 8 words/, + ], + [ + 'a search text over 80 characters', + `{ searchCatalog(search: "${'a'.repeat(81)}") { totalCount } }`, + /at most 80 characters/, + ], + [ + 'a cursor that is not valid', + '{ searchCatalog(after: "nonsense") { totalCount } }', + /cursor is not valid/, + ], + [ + 'an unknown apparatus', + '{ searchCatalog(filter: { apparatusId: "XX" }) { totalCount } }', + /Unknown apparatus/, + ], + [ + 'difficulty together with vault', + '{ searchCatalog(filter: { apparatusId: "VT", difficultyValues: [0.1] }) { totalCount } }', + /cannot be used with vault/, + ], + [ + 'a difficulty value that does not exist', + '{ searchCatalog(filter: { difficultyValues: [0.15] }) { totalCount } }', + /not valid/, + ], + [ + 'difficulty sort without an apparatus', + '{ searchCatalog(sort: { field: DIFFICULTY }) { totalCount } }', + /needs an apparatus filter/, + ], + ])('gives BAD_USER_INPUT for %s', async (_label, query, message) => { + const result = await run(query) + expect(codeOf(result)).toBe('BAD_USER_INPUT') + expect(result.errors![0]!.message).toMatch(message) + }) + + it('rejects a query that is too deep, and accepts one at the limit', async () => { + // apparatus → groups → apparatus → … : each pair adds two levels. + const nest = (levels: number): string => { + let query = 'name' + for (let i = 0; i < levels; i++) + query = i % 2 === 0 ? `apparatus { ${query} }` : `groups { ${query} }` + return `{ apparatus { ${query} } }` + } + const atLimit = nest(MAX_QUERY_DEPTH - 2) + const tooDeep = nest(MAX_QUERY_DEPTH - 1) + + expect((await run(atLimit)).errors).toBeUndefined() + const rejected = await run(tooDeep) + expect(codeOf(rejected)).toBe('GRAPHQL_VALIDATION_FAILED') + expect(rejected.errors![0]!.message).toMatch( + /levels deep. The limit is 8/, + ) + }) + + it('still answers the introspection query that the tools use', async () => { + const { data, errors } = await run<{ + __schema: { types: { name: string }[] } + }>( + '{ __schema { types { name fields { name type { ofType { ofType { ofType { name } } } } } } } }', + ) + expect(errors).toBeUndefined() + expect(data!.__schema.types.some((t) => t.name === 'CatalogItem')).toBe( + true, + ) + }) + + it('hides the details of an unexpected error', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const broken = { + apparatus: { + findMany: () => { + throw new Error('password=secret at db.internal:5432') + }, + }, + } as unknown as PrismaClient + const result = await run('{ apparatus { id } }', undefined, broken) + + expect(codeOf(result)).toBe('INTERNAL_SERVER_ERROR') + expect(result.errors![0]!.message).toBe('Internal server error') + expect(JSON.stringify(result)).not.toContain('secret') + expect(JSON.stringify(result)).not.toContain('stacktrace') + expect(log).toHaveBeenCalled() + } finally { + log.mockRestore() + } + }) + }) + + describe('what is not built yet', () => { + it('answers null for me, because nobody can log in', async () => { + const { data, errors } = await run<{ me: null }>('{ me { id } }') + expect(errors).toBeUndefined() + expect(data!.me).toBeNull() + }) + + it.each([ + ['gymnast', '{ gymnast(id: "x") { id } }', 'UNAUTHENTICATED'], + ['routine', '{ routine(id: "x") { id } }', 'UNAUTHENTICATED'], + [ + 'createGymnast', + 'mutation { createGymnast(name: "A") { id } }', + 'UNAUTHENTICATED', + ], + [ + 'addGymnastElement', + 'mutation { addGymnastElement(gymnastId: "x", itemId: "y") { id } }', + 'UNAUTHENTICATED', + ], + [ + 'login', + 'mutation { login(googleToken: "x") { token } }', + 'NOT_IMPLEMENTED', + ], + [ + 'calculateDScore', + '{ calculateDScore(itemIds: ["UB-1.101-a"]) }', + 'NOT_IMPLEMENTED', + ], + ])('answers a clear error for %s', async (_name, query, code) => { + const result = await run(query) + expect(codeOf(result)).toBe(code) + expect(result.errors![0]!.message).toMatch(/not available yet|logged in/) + }) + + it('has a resolver for every mutation in the schema, so none returns a confusing null', () => { + const mutations = Object.keys( + buildSchema(loadTypeDefs()).getMutationType()!.getFields(), + ) + expect(Object.keys(userDataResolvers.Mutation).sort()).toEqual( + mutations.sort(), + ) + }) + }) + + describe('over HTTP', () => { + let base: string + let closeServer: () => Promise + + beforeAll(async () => { + const { app, server: httpApollo } = await createApp({ prisma, config }) + const listener = app.listen(0, '127.0.0.1') + await new Promise((resolve) => + listener.once('listening', () => resolve()), + ) + base = `http://127.0.0.1:${(listener.address() as AddressInfo).port}` + closeServer = async () => { + await new Promise((resolve) => listener.close(() => resolve())) + await httpApollo.stop() + } + }) + afterAll(async () => { + await closeServer() + }) + + const post = (body: string, headers: Record = {}) => + fetch(`${base}/graphql`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body, + }) + + it('answers a health check and does not say which software it is', async () => { + const response = await fetch(`${base}/health`) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ status: 'ok' }) + expect(response.headers.get('x-powered-by')).toBeNull() + }) + + it('answers a query on /graphql', async () => { + const response = await post( + JSON.stringify({ + query: '{ searchCatalog(search: "hecht", first: 1) { totalCount } }', + }), + ) + expect(response.status).toBe(200) + const body = (await response.json()) as { + data: { searchCatalog: { totalCount: number } } + } + expect(body.data.searchCatalog.totalCount).toBeGreaterThan(0) + }) + + it('serves a drawing that imageUrl points to, and lets the browser keep it', async () => { + const row = await prisma.element.findFirstOrThrow({ + orderBy: { id: 'asc' }, + }) + const url = `${base}/images/${row.id.split('-')[0]}/${row.image}` + const response = await fetch(url) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('image/webp') + expect(response.headers.get('cache-control')).toMatch(/immutable/) + }) + + it('gives 404 for a drawing that does not exist, and does not leave the images folder', async () => { + expect((await fetch(`${base}/images/UB/nope.webp`)).status).toBe(404) + const outside = await fetch(`${base}/images/%2e%2e/%2e%2e/package.json`) + expect(outside.status).not.toBe(200) + }) + + it('lets the allowed origin call the API from a browser, and no other', async () => { + const preflight = (origin: string) => + fetch(`${base}/graphql`, { + method: 'OPTIONS', + headers: { origin, 'access-control-request-method': 'POST' }, + }) + expect( + (await preflight('http://allowed.test')).headers.get( + 'access-control-allow-origin', + ), + ).toBe('http://allowed.test') + expect( + (await preflight('http://other.test')).headers.get( + 'access-control-allow-origin', + ), + ).toBeNull() + }) + + it('refuses a request body above 100 kB', async () => { + const response = await post( + JSON.stringify({ query: `{ __typename } # ${'x'.repeat(200_000)}` }), + ) + expect(response.status).toBe(413) + }) + + it('refuses a query that is not JSON', async () => { + const response = await post('this is not json') + expect(response.status).toBeGreaterThanOrEqual(400) + expect(response.status).toBeLessThan(500) + }) + }) +}) diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index dbce7e0..b5d0da3 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -211,6 +211,23 @@ its own issue. Claude made the changes, checked the query plans with `EXPLAIN ANALYZE`, added tests for each point and updated the docs. +## GraphQL server (`backend/src/server/`, README, `docs/API.md`) + +- **Tool:** Claude Code +- **What the team did:** Asked for the next backend step so that the search + works through GraphQL, and for it to be started as an issue (#28). The + design came from `docs/API.md`: Apollo Server, schema first, DataLoader, no + raw SQL, the error codes and the limits. The team reviews the server and the + choices below. +- **What Claude did:** Rewrote issue #28, set up the server on Express, wrote + the catalog resolvers, the loaders, the depth limit, the error mapping, the + settings and the tests, and updated the README and `docs/API.md`. The tests + found two defects that were fixed: `totalCount` was calculated even when the + query left it out with `@include(if: false)`, and Apollo's + `unwrapResolverError` did not recognise errors when two copies of the + `graphql` package are loaded. Claude also started the server and tried + queries, images, CORS and the error cases over HTTP. + ## Database Infrastructure & Setup Completion (`backend/package.json`, `backend/.env.example`, `docs/VM_SETUP.md`, `docs/AI_USAGE.md`, `src/App.tsx`) - **Tool:** ChatGPT & Gemini diff --git a/docs/API.md b/docs/API.md index c9f4674..60fd3db 100644 --- a/docs/API.md +++ b/docs/API.md @@ -164,8 +164,8 @@ The requirement is that the solution works with any amount of data. The catalog ### Protecting the server -- **No N+1 queries.** A list of 20 items that each asks for `elementGroup` and `variants` would give 41 database queries. DataLoader collects the lookups of one request into one query for each kind. This is needed for `elementGroup`, `variants`, `Gymnast.routines`, `Gymnast.elements`, `Routine.items` and `Routine.apparatus`. -- **Limit the depth and cost of a query.** The schema has cycles (`Apparatus` → `groups` → `apparatus` → …), so a client could ask for a very deep query. The server must reject queries deeper than a fixed depth (for example 6) and, if needed, set a cost limit. +- **No N+1 queries.** A list of 20 items that each asks for `elementGroup` and `variants` would give 41 database queries. DataLoader collects the lookups of one request into one query for each kind. This is done for the catalog (the element or vault behind a search result, the variants of a code and the apparatus of a group, in `backend/src/server/loaders.ts`). It is still to do for `Gymnast.routines`, `Gymnast.elements`, `Routine.items` and `Routine.apparatus` when the user data is built. +- **Limit the depth and cost of a query.** The schema has cycles (`Apparatus` → `groups` → `apparatus` → …), so a client could ask for a very deep query. The server rejects a query deeper than 8 levels (`MAX_QUERY_DEPTH` in `backend/src/server/depthLimit.ts`) with `GRAPHQL_VALIDATION_FAILED`, before anything runs. The introspection fields are not counted, because the tools that read the schema ask a deep question on purpose. A request body above 100 kB is refused. A cost limit is not set. - **Limit the size of the arguments.** `calculateDScore` and `setRoutineItems` take at most 50 ids. ### User data @@ -237,15 +237,66 @@ An error response looks like this: The price of this choice is that the types do not force the client to handle the errors. We handle that with tests of the error cases (see the testing section in the README when it exists). +## The server + +The server is built (issue #28) in `backend/src/server/`. It answers the catalog queries and the search. The login and the user data are not built yet. + +**Run it:** `npm run backend` (restarts on changes) or `npm run start -w backend`, after the database is migrated and seeded. The settings are in `backend/.env` (see Configuration). The code is split so each part can be read and tested on its own: + +| File | What it does | +| ------------------------ | ------------------------------------------------------------------------------------------- | +| `index.ts` | Starts the server and shuts it down cleanly on `SIGINT` and `SIGTERM` | +| `server/app.ts` | Builds the Express app and the Apollo server: `/graphql`, `/images`, `/health` | +| `server/config.ts` | Reads and checks the settings; a wrong value stops the start with a clear message | +| `server/resolvers/` | `catalog.ts` (done) and `userData.ts` (answers errors until the login exists) | +| `server/loaders.ts` | The DataLoaders, one set for each request | +| `server/depthLimit.ts` | The depth limit as a validation rule | +| `server/formatError.ts` | How errors look to the client | +| `server/selectsField.ts` | Whether the query asks for a field, so `totalCount` is only calculated when it is asked for | + +The scripts are in `backend/package.json`: `dev` and `start` run `src/index.ts` with `tsx`. The server runs from the source, because `tsconfig.json` has `noEmit` and there is no `dist/` to run. `setup` runs the migrations and then the seed (`prisma migrate deploy` and `prisma db seed`), which is what a new database needs, such as the one on the virtual machine (issue #26). `build` only checks the types. + +### Addresses + +| Address | What it gives | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `POST /graphql` | The API. In a browser, `GET /graphql` opens Apollo Sandbox when the server is not in production | +| `GET /images//` | The drawings in `data/images/`, kept by the browser for 30 days. On the virtual machine Apache serves them | +| `GET /health` | `{"status":"ok"}`. It does not use the database, so a process manager or a proxy can check that the server is up | + +### Decisions + +- **`variants` lists the other variants** of a code, without the item itself, and without variants that are removed. The number of variants of a code is therefore `variants.length + 1`. +- **`imageUrl`** is `IMAGE_BASE_URL`, the apparatus (the first part of the id) and the file name. Moving the drawings only changes `IMAGE_BASE_URL`. +- **`value` and `dValue`** are `Decimal` in the database and are converted to numbers at the end, where they are sent. +- **A search result is a catalog entry** (one per code). The element or vault behind each result is loaded in one batch by DataLoader, so a page costs a fixed number of queries (4–6 in the tests) whether it has 5 or 100 results. +- **`totalCount` is only calculated when the query asks for it**, also through fragments, and not when `@skip` or `@include` leaves it out. A client that wants the count only on the first page writes `totalCount @include(if: $firstPage)`. +- **Errors.** `BadUserInputError` and the other errors in `backend/src/errors.ts` get their code in `extensions.code`. Errors from GraphQL itself (an invalid query) keep their code. Anything else gives `INTERNAL_SERVER_ERROR` with the message "Internal server error": the details go to the log and never to the client. `formatError` reads `originalError` directly instead of using Apollo's `unwrapResolverError`, because that function fails when two copies of the `graphql` package are loaded. +- **A request body above 100 kB is refused** (413). The CORS allow-list is `CORS_ORIGIN`; a browser on another address cannot call the API. +- **`graphql` is pinned to version 16.** Apollo Server 5 needs `^16`, and the latest release is 17. +- **`tsx` and `dotenv` are regular dependencies**, because the server runs with `tsx` and loads `backend/.env`. Install with `npm ci`, not with `--omit=dev`. + +### What is not built yet + +Until the login and the user data exist, every such operation answers a clear error, so the schema can be used as it is and nothing returns a confusing `null`: + +| Operation | Answer now | +| --------------------------------------------------------------------- | ----------------- | +| `me` | `null` | +| `gymnast`, `routine`, and every mutation that needs a logged-in coach | `UNAUTHENTICATED` | +| `login`, `calculateDScore` | `NOT_IMPLEMENTED` | + +`NOT_IMPLEMENTED` is temporary and is not in the table of error codes. It goes away when the login and the calculator are built. A test checks that every mutation in the schema has a resolver, so a new mutation cannot be forgotten. + ## Tools -These are the choices for the implementation. They are proposals until the backend is built, and the team can change them. +These are the choices for the implementation. The server is built with them (issue #28), except where it says that something is still to do. The team can change them. ### Server and types -- **Apollo Server** for the GraphQL server. It has the most documentation, it works with Apollo Client (which we use for the cache and for the local state in the frontend), and it has Apollo Sandbox for trying queries in the browser. +- **Apollo Server 5** on Express 5 for the GraphQL server (`graphql` is pinned to version 16, which Apollo needs). It has the most documentation, it works with Apollo Client (which we use for the cache and for the local state in the frontend), and it has Apollo Sandbox for trying queries in the browser. - **Schema first.** We write `schema.graphql` by hand and the resolvers follow it. The file is easy to read for someone who reviews the project, and the schema is the contract between the frontend and the backend. The alternative, generating the schema from TypeScript code (code first), hides the contract in the code. -- **GraphQL Code Generator** makes the TypeScript types for the resolvers (backend) and for the queries (frontend) from the same schema file. A change in the schema then shows up as a type error in both places. +- **GraphQL Code Generator** (not set up yet) makes the TypeScript types for the resolvers (backend) and for the queries (frontend) from the same schema file. A change in the schema then shows up as a type error in both places. ### Search without raw SQL @@ -329,17 +380,18 @@ The alternative for much larger systems is a search engine such as Meilisearch o ### N+1 queries -We use **DataLoader**, which collects the lookups of one request and sends one query. Prisma already batches `findUnique` calls made at the same time, so the loaders matter most for lists of related rows, which Prisma does not combine: `Gymnast.routines`, `Element.variants` and `Routine.items`. A loader must return the rows in the same order as the ids it got (for `Routine.items`, in the order of the routine). Create the loaders for each request, so that data does not leak between users. +We use **DataLoader**, which collects the lookups of one request and sends one query. Prisma already batches `findUnique` calls made at the same time, so the loaders matter most for lists of related rows, which Prisma does not combine: `Gymnast.routines`, `Element.variants` and `Routine.items`. A loader must return the rows in the same order as the ids it got (for `Routine.items`, in the order of the routine). Create the loaders for each request, so that data does not leak between users. This is how `backend/src/server/context.ts` does it: a new set of loaders for every request. ## Configuration -The settings are read from environment variables, and an example file with all names (but without secrets) is part of the repository. The names are proposals: +The settings are read from environment variables, and an example file with all names (but without secrets) is `backend/.env.example`. The first four are in use. The last two come with the login. | Variable | Used for | | ------------------ | -------------------------------------------------------- | | `PORT` | The port of the server, `3001` | | `DATABASE_URL` | The PostgreSQL connection for Prisma | | `IMAGE_BASE_URL` | The address where the drawings are served (see Images) | +| `CORS_ORIGIN` | The addresses that may call the API from a browser | | `GOOGLE_CLIENT_ID` | Checks that a Google token was made for this application | | `SESSION_SECRET` | The key that signs the session tokens | diff --git a/package-lock.json b/package-lock.json index 7b2b74b..86b1715 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,33 +59,28 @@ "backend": { "version": "0.0.0", "dependencies": { - "@prisma/adapter-pg": "7.10.0", - "@prisma/client": "^7.10.1" + "@apollo/server": "^5.5.1", + "@as-integrations/express5": "^1.1.2", + "@prisma/adapter-pg": "^7.10.1", + "@prisma/client": "^7.10.1", + "cors": "^2.8.6", + "dataloader": "^2.2.3", + "dotenv": "^18.0.8", + "express": "^5.3.0", + "graphql": "^16.14.2", + "tsx": "^4.23.15" }, "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", "@types/node": "^24.19.2", - "dotenv": "^17.4.2", "prisma": "^7.10.1", - "tsx": "^4.23.15", "typescript": "~6.0.2" }, "engines": { "node": ">=24" } }, - "backend/node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/@apollo/cache-control-types": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", @@ -347,6 +342,19 @@ "node": ">=16" } }, + "node_modules/@as-integrations/express5": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@as-integrations/express5/-/express5-1.1.2.tgz", + "integrity": "sha512-BxfwtcWNf2CELDkuPQxi5Zl3WqY/dQVJYafeCBOGoFQjv5M0fjhxmAFZ9vKx/5YKKNeok4UY6PkFbHzmQrdxIA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@apollo/server": "^4.0.0 || ^5.0.0", + "express": "^5.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -714,7 +722,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -731,7 +738,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -748,7 +754,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -765,7 +770,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -782,7 +786,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -799,7 +802,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -816,7 +818,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -833,7 +834,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -850,7 +850,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -867,7 +866,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -884,7 +882,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -901,7 +898,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -918,7 +914,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -935,7 +930,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -952,7 +946,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -969,7 +962,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -986,7 +978,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1003,7 +994,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1020,7 +1010,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1037,7 +1026,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1054,7 +1042,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1071,7 +1058,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1088,7 +1074,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1105,7 +1090,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1122,7 +1106,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1139,7 +1122,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1526,12 +1508,12 @@ } }, "node_modules/@prisma/adapter-pg": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz", - "integrity": "sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==", + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.1.tgz", + "integrity": "sha512-EP9ruPYHjKP80WZlb5qVaEXKgZjJieBFjAxcBtQnDAHD6+16UN4keVmBh6L/RUpuDQT2FQRbvKNgq5d4Ix+IkQ==", "license": "Apache-2.0", "dependencies": { - "@prisma/driver-adapter-utils": "7.10.0", + "@prisma/driver-adapter-utils": "7.10.1", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" @@ -1568,9 +1550,9 @@ "license": "Apache-2.0" }, "node_modules/@prisma/debug": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.0.tgz", - "integrity": "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==", + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.1.tgz", + "integrity": "sha512-eFCAkGZ13D/qPg48JBlG5IWi9cSBBOC7oG3abDPcAkYioDWlIjh0YJJLK2J7qo5S+xWWD8I6egAN/8KbCaiMCA==", "license": "Apache-2.0" }, "node_modules/@prisma/dev": { @@ -1605,12 +1587,12 @@ "license": "MIT" }, "node_modules/@prisma/driver-adapter-utils": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.10.0.tgz", - "integrity": "sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA==", + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.10.1.tgz", + "integrity": "sha512-DJ4c6xvVnm3s9ZUyql9nCUicADSqyvH+CjXRCM9L6m5zJxCNmebCElG6X4irICq3rosBwlX+S19Dxq/4IrZk3A==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.10.0" + "@prisma/debug": "7.10.1" } }, "node_modules/@prisma/get-platform": { @@ -2847,6 +2829,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2858,6 +2851,16 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -2978,6 +2981,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -2985,6 +3013,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3025,6 +3060,20 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", @@ -3045,6 +3094,27 @@ "@types/react": "^19.3.0" } }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.71.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", @@ -3519,6 +3589,19 @@ "node": ">=22.15.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -3946,6 +4029,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -3962,6 +4058,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -4151,6 +4265,12 @@ "node": ">=12" } }, + "node_modules/dataloader": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", + "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4382,7 +4502,6 @@ "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -4660,6 +4779,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -4670,6 +4798,62 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/exsolve": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", @@ -4887,11 +5071,28 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5213,6 +5414,15 @@ "node": ">=12" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -5248,6 +5458,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -5759,6 +5975,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -5938,6 +6166,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6017,6 +6254,16 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -6332,13 +6579,6 @@ "empathic": "2.0.0" } }, - "node_modules/prisma/node_modules/@prisma/debug": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.1.tgz", - "integrity": "sha512-eFCAkGZ13D/qPg48JBlG5IWi9cSBBOC7oG3abDPcAkYioDWlIjh0YJJLK2J7qo5S+xWWD8I6egAN/8KbCaiMCA==", - "devOptional": true, - "license": "Apache-2.0" - }, "node_modules/prisma/node_modules/@prisma/engines": { "version": "7.10.1", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.1.tgz", @@ -6443,6 +6683,23 @@ "devOptional": true, "license": "ISC" }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6506,6 +6763,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/raw-body": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", @@ -6654,6 +6924,22 @@ "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -6719,6 +7005,51 @@ "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7032,7 +7363,6 @@ "version": "4.23.15", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -7472,6 +7802,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index b20f3d3..30329df 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "format": "prettier --write .", "test": "vitest", "preview": "vite preview", + "backend": "npm run dev -w backend", "db:generate": "npm run prisma:generate -w backend", "db:migrate": "npm run prisma:migrate -w backend", "db:deploy": "npm run prisma:deploy -w backend",