Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ dist
dist-ssr
*.local

# Prisma client, generated by `npm run db:generate`
# The Prisma client (`npm run db:generate`) and the GraphQL types (`npm run codegen`)
backend/src/generated

# Environment variables
Expand Down
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,20 +223,22 @@ From the repository root:

```bash
npm ci
npm run db:generate
npm run generate
npm run db:migrate
npm run db:seed
```

`npm run db:migrate` creates the tables in your database by applying the migrations in `backend/prisma/migrations/`. Run it again whenever someone has added a new migration.

| Command | What it does |
| --------------------- | ------------------------------------------------------------------------------ |
| `npm run db:generate` | Generates the Prisma client from `backend/prisma/schema.prisma` |
| `npm run db:migrate` | Creates a migration from changes in the schema and applies it to your database |
| `npm run db:deploy` | Applies existing migrations without creating new ones. Used on the server |
| `npm run db:seed` | Loads the catalog from `data/` into the database. See "Seeding" below |
| `npm run db:studio` | Opens Prisma Studio in the browser, to look at the data in the database |
| Command | What it does |
| --------------------- | ---------------------------------------------------------------------------------- |
| `npm run generate` | Runs both generators below. `typecheck` and `build` in `backend` run it first |
| `npm run db:generate` | Generates the Prisma client from `backend/prisma/schema.prisma` |
| `npm run codegen` | Generates the TypeScript types for the resolvers from `backend/src/schema.graphql` |
| `npm run db:migrate` | Creates a migration from changes in the schema and applies it to your database |
| `npm run db:deploy` | Applies existing migrations without creating new ones. Used on the server |
| `npm run db:seed` | Loads the catalog from `data/` into the database. See "Seeding" below |
| `npm run db:studio` | Opens Prisma Studio in the browser, to look at the data in the database |

### 5. Check the connection

Expand Down
39 changes: 39 additions & 0 deletions backend/codegen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// GraphQL Code Generator: makes TypeScript types from src/schema.graphql, so a
// resolver that does not match the schema fails `tsc`. Run it with
// `npm run codegen`. The result (src/generated/graphql.ts) is not committed,
// like the Prisma client in the same folder.
import type { CodegenConfig } from '@graphql-codegen/cli'

const config: CodegenConfig = {
schema: 'src/schema.graphql',
generates: {
'src/generated/graphql.ts': {
plugins: ['typescript', 'typescript-resolvers'],
config: {
// What every resolver gets as its third argument.
contextType: '../server/context.js#Context',
useTypeImports: true,
enumsAsTypes: true,
// GraphQL types that resolvers receive as database rows. The fields of
// the row do not have to match the schema: a field resolver converts
// them (for example Decimal to number).
mappers: {
Apparatus: '../generated/prisma/client.js#Apparatus as ApparatusRow',
ElementGroup:
'../generated/prisma/client.js#ElementGroup as ElementGroupRow',
Coach: '../generated/prisma/client.js#Coach as CoachRow',
Gymnast: '../generated/prisma/client.js#Gymnast as GymnastRow',
Routine: '../generated/prisma/client.js#Routine as RoutineRow',
// An element or vault row with a __typename, see server/items.ts.
CatalogItem: '../server/items.js#Item',
CatalogConnection: '../server/items.js#ConnectionRow',
Element: '../server/items.js#ElementItem',
Vault: '../server/items.js#VaultItem',
},
scalars: { DateTime: 'Date' },
},
},
},
}

export default config
7 changes: 7 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"prebuild": "npm run generate",
"build": "tsc",
"setup": "DATABASE_URL=${MIGRATION_DATABASE_URL:-$DATABASE_URL} prisma migrate deploy && DATABASE_URL=${MIGRATION_DATABASE_URL:-$DATABASE_URL} npm run prisma:seed",
"pretypecheck": "npm run generate",
"typecheck": "tsc --noEmit",
"generate": "npm run prisma:generate && npm run codegen",
"prisma:generate": "prisma generate",
"codegen": "graphql-codegen --config codegen.ts",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "prisma db seed",
Expand All @@ -19,6 +23,9 @@
"node": ">=24"
},
"devDependencies": {
"@graphql-codegen/cli": "^7.4.6",
"@graphql-codegen/typescript": "^6.1.2",
"@graphql-codegen/typescript-resolvers": "^6.1.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^24.19.2",
Expand Down
15 changes: 15 additions & 0 deletions backend/src/server/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,18 @@ export const asVault = (row: Vault): VaultItem => ({
...row,
__typename: 'Vault',
})

export const isElementItem = (item: Item): item is ElementItem =>
item.__typename === 'Element'
export const isVaultItem = (item: Item): item is VaultItem =>
item.__typename === 'Vault'

/**
* What the search resolver returns for CatalogConnection. `totalCount` is only
* there when the query asked for it (it costs a count over all the matches).
*/
export type ConnectionRow = {
edges: { cursor: string; node: Item }[]
pageInfo: { hasNextPage: boolean; endCursor: string | null }
totalCount?: number
}
3 changes: 2 additions & 1 deletion backend/src/server/resolvers/calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { calculateDScore, type ScoredItem } from '../../dscore.js'
import { BadUserInputError, InvalidItemsError } from '../../errors.js'
import type { Resolvers } from '../../generated/graphql.js'
import type { Context } from '../context.js'

/** The most ids one call may have (also in docs/API.md). */
Expand Down Expand Up @@ -61,4 +62,4 @@ export const calculatorResolvers = {
return calculateDScore(items)
},
},
}
} satisfies Resolvers<Context>
54 changes: 39 additions & 15 deletions backend/src/server/resolvers/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// and the loaders collect the database lookups (see loaders.ts).

import type { GraphQLResolveInfo } from 'graphql'
import type { Resolvers } from '../../generated/graphql.js'
import type { Apparatus, ElementGroup } from '../../generated/prisma/client.js'
import {
type CatalogSearchArgs,
Expand All @@ -12,10 +13,13 @@ import {
import { catalogStructure } from '../../catalog/structure.js'
import type { Context } from '../context.js'
import {
type ConnectionRow,
type ElementItem,
type Item,
type VaultItem,
apparatusOfId,
isElementItem,
isVaultItem,
} from '../items.js'
import { selectsField } from '../selectsField.js'

Expand Down Expand Up @@ -43,17 +47,13 @@ const itemFields = {
},

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<Item[]> => {
if (item.variantGroupId === null) return []
const members = await ctx.loaders.variantsOfGroup.load(item.variantGroupId)
return members.filter((member) => member.id !== item.id)
},
/** The other ways of performing the item that are still in the Code of Points. */
async function otherVariants(item: Item, ctx: Context): Promise<Item[]> {
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 = {
Expand Down Expand Up @@ -112,7 +112,13 @@ export const catalogResolvers = {
}
return { cursor: edge.cursor, node: item }
})
return { edges, pageInfo: result.pageInfo, totalCount: result.totalCount }
return {
edges,
pageInfo: result.pageInfo,
...(result.totalCount !== undefined
? { totalCount: result.totalCount }
: {}),
}
},
},

Expand All @@ -125,12 +131,26 @@ export const catalogResolvers = {
},

ElementGroup: {
apparatus: (
apparatus: async (
group: ElementGroup,
_args: unknown,
ctx: Context,
): Promise<Apparatus | null> =>
ctx.loaders.apparatusById.load(group.apparatusId),
): Promise<Apparatus> => {
const apparatus = await ctx.loaders.apparatusById.load(group.apparatusId)
// The database guarantees it (foreign key), and the schema says it is never null.
if (!apparatus)
throw new Error(`The apparatus ${group.apparatusId} does not exist.`)
return apparatus
},
},

CatalogConnection: {
/** Only calculated when the query asks for it, see selectsField. */
totalCount: (connection: ConnectionRow): number => {
if (connection.totalCount === undefined)
throw new Error('totalCount was not calculated for this query.')
return connection.totalCount
},
},

/** Tells GraphQL which type an item is: the rows carry a `__typename`. */
Expand All @@ -141,10 +161,14 @@ export const catalogResolvers = {
Element: {
...itemFields,
value: (item: ElementItem): number => item.value.toNumber(),
variants: async (item: ElementItem, _args: unknown, ctx: Context) =>
(await otherVariants(item, ctx)).filter(isElementItem),
},

Vault: {
...itemFields,
dValue: (item: VaultItem): number => item.dValue.toNumber(),
variants: async (item: VaultItem, _args: unknown, ctx: Context) =>
(await otherVariants(item, ctx)).filter(isVaultItem),
},
}
} satisfies Resolvers<Context>
4 changes: 3 additions & 1 deletion backend/src/server/resolvers/userData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
// user data.

import { NotImplementedError, UnauthenticatedError } from '../../errors.js'
import type { Resolvers } from '../../generated/graphql.js'
import type { Context } from '../context.js'

const loginRequired = (): never => {
throw new UnauthenticatedError(
Expand Down Expand Up @@ -39,4 +41,4 @@ export const userDataResolvers = {
deleteRoutine: loginRequired,
setRoutineItems: loginRequired,
},
}
} satisfies Resolvers<Context>
2 changes: 1 addition & 1 deletion backend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "prisma.config.ts"]
"include": ["src", "prisma.config.ts", "codegen.ts"]
}
13 changes: 13 additions & 0 deletions docs/AI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,19 @@
`INVALID_ITEMS` error class, the tests and the documentation, and checked
that the number of queries does not grow with the number of ids.

## GraphQL Code Generator (`backend/codegen.ts`)

- **Tool:** Claude Code
- **What the team did:** Wrote issue #74: types made from the schema, so a change in
the schema shows up as a type error in the resolvers.
- **What Claude did:** Set up `@graphql-codegen/cli` with the `typescript` and
`typescript-resolvers` plugins, mapped the database rows to the GraphQL types,
typed the resolvers with `satisfies Resolvers<Context>`, and showed that renaming
a field in the schema fails `tsc`. The types found two mismatches in the
catalog resolvers (a possible `null` for `ElementGroup.apparatus` and a missing
`totalCount`), which Claude fixed. `npm audit` reports a `braces` problem in
the generator's own dependencies (a build tool, not part of the server).


## Database Infrastructure & Setup Completion (`backend/package.json`, `backend/.env.example`, `docs/VM_SETUP.md`, `docs/AI_USAGE.md`, `src/App.tsx`)

Expand Down
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ These are the choices for the implementation. The server is built with them (iss

- **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** (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.
- **GraphQL Code Generator** makes the TypeScript types for the resolvers from the same schema file (issue #74), and will make the types for the queries in the frontend. A change in the schema then shows up as a type error in both places. Run `npm run codegen` (in `backend/`, config in `backend/codegen.ts`) after `npm ci` and after every change in `schema.graphql`. It writes `backend/src/generated/graphql.ts`, which is not committed, like the Prisma client next to it, so `tsc` and `npm run lint` need it. The resolver files end with `satisfies Resolvers<Context>`: a resolver for a field that is not in the schema, with the wrong arguments or with the wrong return type fails `tsc`. The database rows that resolvers return are mapped in `codegen.ts` (`Apparatus`, `ElementGroup`, `Coach`, `Gymnast`, `Routine`, and `Element`, `Vault`, `CatalogItem` and `CatalogConnection` from `server/items.ts`). The first run found two mismatches that are now fixed: `ElementGroup.apparatus` could be `null` although the schema says it never is, and `totalCount` could be missing when the query did not ask for it (it now has its own resolver that fails clearly).

### Search without raw SQL

Expand Down
Loading