Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. The GraphQL server and the search are the next steps.
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 code in `src/` is the Vite starter project with one example test. The tooling is set up: TypeScript, ESLint, Prettier and Vitest.

Expand Down Expand Up @@ -64,6 +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
- ESLint and Prettier for linting and formatting, for both the frontend and the backend
- Vitest for testing

Expand Down Expand Up @@ -256,11 +257,19 @@ 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.

## 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).

The tests in `backend/src/catalog/` run with `npm test`. The search tests use your local database and need the seeded catalog, so run `npm run db:seed` first. They only read from the database.

**Without `DATABASE_URL` in `backend/.env`, the database tests are skipped.** Vitest then reports them as skipped and the run still passes, so a passing run without a database does not mean the search was tested. Check that the output says 0 skipped.

## Project structure

| Folder | Contents |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `backend/` | The backend package: TypeScript, Prisma and the draft GraphQL schema |
| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search and the draft 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 |
Expand Down
97 changes: 97 additions & 0 deletions backend/src/catalog/cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// The cursor of the catalog search: a text that says where the next page
// starts. It is base64url-encoded JSON with the sort field and direction, the
// sort values of the last item on the page, and its id.
//
// The client should treat it as opaque, but it is not secret: the catalog is
// public, so a changed or hand-made cursor can only lead to another page of
// public data. What matters is that it is checked strictly when it is decoded,
// before any value reaches Prisma.

import { BadUserInputError } from './errors.js'

export type SortField = 'CODE' | 'DIFFICULTY' | 'NAME'
export type SortDirection = 'ASC' | 'DESC'

/** The sort values of the last item on a page, plus its id. */
export type CursorValues =
| { field: 'CODE'; codeSort: string; id: string }
| { field: 'DIFFICULTY'; difficulty: string; id: string }
| { field: 'NAME'; hasName: boolean; nameSort: string; id: string }

/** The keys a cursor for each sort field must have, and no others. */
const KEYS: Record<SortField, string[]> = {
CODE: ['codeSort', 'direction', 'field', 'id'],
DIFFICULTY: ['difficulty', 'direction', 'field', 'id'],
NAME: ['direction', 'field', 'hasName', 'id', 'nameSort'],
}

const INVALID =
'The cursor is not valid. Use the endCursor from the previous page.'

export function encodeCursor(
values: CursorValues,
direction: SortDirection,
): string {
return Buffer.from(JSON.stringify({ ...values, direction }), 'utf8').toString(
'base64url',
)
}

const isText = (v: unknown): v is string =>
typeof v === 'string' && v.length <= 200
const isId = (v: unknown): v is string => isText(v) && v.length > 0

/**
* Decodes and checks a cursor: it must be for the same sort field and
* direction as the current request, have exactly the expected keys, and every
* value must have the right type. Anything else gives BAD_USER_INPUT.
*/
export function decodeCursor(
cursor: string,
field: SortField,
direction: SortDirection,
): CursorValues {
let payload: unknown
try {
payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
} catch {
throw new BadUserInputError(INVALID)
}
if (
typeof payload !== 'object' ||
payload === null ||
Array.isArray(payload)
) {
throw new BadUserInputError(INVALID)
}

const p = payload as Record<string, unknown>
if (p['field'] !== field || p['direction'] !== direction) {
throw new BadUserInputError(
`The cursor was made for another sort (${String(p['field'])} ${String(p['direction'])}), not ${field} ${direction}. Start again without a cursor.`,
)
}
if (Object.keys(p).sort().join() !== KEYS[field].join() || !isId(p['id'])) {
throw new BadUserInputError(INVALID)
}

const id = p['id']
if (field === 'CODE' && isText(p['codeSort'])) {
return { field, codeSort: p['codeSort'], id }
}
if (
field === 'DIFFICULTY' &&
isText(p['difficulty']) &&
/^\d+(\.\d)?$/.test(p['difficulty'])
) {
return { field, difficulty: p['difficulty'], id }
}
if (
field === 'NAME' &&
typeof p['hasName'] === 'boolean' &&
isText(p['nameSort'])
) {
return { field, hasName: p['hasName'], nameSort: p['nameSort'], id }
}
throw new BadUserInputError(INVALID)
}
13 changes: 13 additions & 0 deletions backend/src/catalog/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* 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"
* in docs/API.md).
*/
export class BadUserInputError extends Error {
readonly code = 'BAD_USER_INPUT'

constructor(message: string) {
super(message)
this.name = 'BadUserInputError'
}
}
Loading