diff --git a/README.md b/README.md
index 19b58ae..144b34b 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/).
+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, the GraphQL server and the search 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.
@@ -61,14 +61,16 @@ How the API stays fast when the catalog grows (cursors, indexes, text search, li
In the repository now:
- React, TypeScript and Vite for the frontend
-- A backend package in `backend/` with TypeScript and Prisma, set up as an npm workspace. The database models come in a later issue.
+- 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
- ESLint and Prettier for linting and formatting, for both the frontend and the backend
- Vitest for testing
Planned:
- A GraphQL backend in Node and TypeScript
-- PostgreSQL 18, installed directly on the VM and on our own machines (not in Docker), accessed with Prisma
+- A seed script that loads `data/` into the database
+- PostgreSQL 18 on the VM, set up the same way as on our own machines
### Why PostgreSQL
@@ -84,11 +86,11 @@ We also considered MongoDB and SQLite:
- **MongoDB:** the list of element ids in a routine fits a document. But the catalog is joined to routines, groups and variants all the time, and the constraints would have to live in the backend code.
- **SQLite:** simple to set up, but it is one file with limited concurrent writes, which is a poor fit for a server with several users.
-Open: how the ordered list of elements in a routine is stored. A join table with a position column keeps the foreign keys, while an array of ids is simpler. We decide this when the Prisma schema is written.
+The ordered list of elements in a routine is stored as a join table with a position column, which keeps the foreign keys. How each list in the data model is stored is described in "Storage in PostgreSQL" in [docs/DATA_MODEL.md](docs/DATA_MODEL.md#storage-in-postgresql).
## Getting started
-> **Not relevant yet.** The first delivery is a specification, so you do not need to run anything to review it. These commands only start the starter project.
+> The frontend in `src/` is still the Vite starter project, so `npm run dev` only shows the starter page. To work on the backend, also follow "Database (local setup)" below.
You need Node 24 (see `.nvmrc`). The project uses npm; do not use pnpm or yarn, so that everyone gets the versions in `package-lock.json`.
@@ -170,8 +172,11 @@ From the repository root:
```bash
npm ci
npm run db:generate
+npm run db:migrate
```
+`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` |
@@ -196,10 +201,12 @@ To check that the backend can reach your database:
npx prisma migrate status
```
-4. Look for this line in the output. If it is there, the connection works:
+4. Look for these lines in the output. The first one means that the connection works, and the last one that your tables are up to date:
```
Datasource "db": PostgreSQL database "cop", schema "public" at "localhost:5432"
+ ...
+ Database schema is up to date!
```
5. Go back to the repository root:
@@ -208,7 +215,7 @@ To check that the backend can reach your database:
cd ..
```
-On a fresh setup the output also says "No migration found in prisma/migrations" and "The current database is not managed by Prisma Migrate". That is the expected result and not an error: there are no tables or migrations yet, because the models are added in a later issue. If the connection fails, Prisma prints an error such as "Can't reach database server" or "Authentication failed" instead.
+If the output says "Following migration have not yet been applied", the connection works but your tables are missing or out of date. Go back to the repository root and run `npm run db:migrate`. If the connection fails, Prisma prints an error such as "Can't reach database server" or "Authentication failed" instead.
## Project structure
diff --git a/backend/package.json b/backend/package.json
index 0098664..29582f7 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -21,6 +21,7 @@
"typescript": "~6.0.2"
},
"dependencies": {
+ "@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0"
}
}
diff --git a/backend/prisma/migrations/20261015072132_create_data_model/migration.sql b/backend/prisma/migrations/20261015072132_create_data_model/migration.sql
new file mode 100644
index 0000000..8f7430d
--- /dev/null
+++ b/backend/prisma/migrations/20261015072132_create_data_model/migration.sql
@@ -0,0 +1,272 @@
+-- CreateEnum
+CREATE TYPE "catalog_entry_type" AS ENUM ('ELEMENT', 'VAULT');
+
+-- CreateTable
+CREATE TABLE "apparatus" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "position" INTEGER NOT NULL,
+
+ CONSTRAINT "apparatus_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "element_groups" (
+ "id" TEXT NOT NULL,
+ "apparatus_id" TEXT NOT NULL,
+ "number" INTEGER NOT NULL,
+ "name" TEXT NOT NULL,
+
+ CONSTRAINT "element_groups_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "variant_groups" (
+ "id" TEXT NOT NULL,
+
+ CONSTRAINT "variant_groups_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "elements" (
+ "id" TEXT NOT NULL,
+ "element_group_id" TEXT NOT NULL,
+ "variant_group_id" TEXT,
+ "code" TEXT NOT NULL,
+ "value" DECIMAL(3,1) NOT NULL,
+ "description" TEXT NOT NULL,
+ "name" TEXT,
+ "image" TEXT NOT NULL,
+ "removed_at" TIMESTAMPTZ(3),
+
+ CONSTRAINT "elements_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "vaults" (
+ "id" TEXT NOT NULL,
+ "element_group_id" TEXT NOT NULL,
+ "variant_group_id" TEXT,
+ "code" TEXT NOT NULL,
+ "d_value" DECIMAL(3,1) NOT NULL,
+ "description" TEXT NOT NULL,
+ "name" TEXT,
+ "image" TEXT NOT NULL,
+ "removed_at" TIMESTAMPTZ(3),
+
+ CONSTRAINT "vaults_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "catalog_entries" (
+ "id" TEXT NOT NULL,
+ "type" "catalog_entry_type" NOT NULL,
+ "code" TEXT NOT NULL,
+ "name" TEXT,
+ "description" TEXT NOT NULL,
+ "difficulty" DECIMAL(3,1) NOT NULL,
+ "apparatus_id" TEXT NOT NULL,
+ "element_group_id" TEXT NOT NULL,
+ "has_name" BOOLEAN NOT NULL,
+ "name_sort" TEXT NOT NULL,
+ "code_sort" TEXT NOT NULL,
+
+ CONSTRAINT "catalog_entries_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "catalog_words" (
+ "entry_id" TEXT NOT NULL,
+ "word" TEXT NOT NULL,
+
+ CONSTRAINT "catalog_words_pkey" PRIMARY KEY ("entry_id","word")
+);
+
+-- CreateTable
+CREATE TABLE "coaches" (
+ "id" TEXT NOT NULL,
+ "google_id" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "coaches_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "gymnasts" (
+ "id" TEXT NOT NULL,
+ "coach_id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+
+ CONSTRAINT "gymnasts_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "gymnast_elements" (
+ "id" SERIAL NOT NULL,
+ "gymnast_id" TEXT NOT NULL,
+ "element_id" TEXT,
+ "vault_id" TEXT,
+
+ CONSTRAINT "gymnast_elements_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "routines" (
+ "id" TEXT NOT NULL,
+ "gymnast_id" TEXT NOT NULL,
+ "apparatus_id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ(3) NOT NULL,
+
+ CONSTRAINT "routines_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "routine_elements" (
+ "routine_id" TEXT NOT NULL,
+ "position" INTEGER NOT NULL,
+ "element_id" TEXT,
+ "vault_id" TEXT,
+
+ CONSTRAINT "routine_elements_pkey" PRIMARY KEY ("routine_id","position")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "apparatus_position_key" ON "apparatus"("position");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "element_groups_apparatus_id_number_key" ON "element_groups"("apparatus_id", "number");
+
+-- CreateIndex
+CREATE INDEX "elements_element_group_id_idx" ON "elements"("element_group_id");
+
+-- CreateIndex
+CREATE INDEX "elements_variant_group_id_idx" ON "elements"("variant_group_id");
+
+-- CreateIndex
+CREATE INDEX "vaults_element_group_id_idx" ON "vaults"("element_group_id");
+
+-- CreateIndex
+CREATE INDEX "vaults_variant_group_id_idx" ON "vaults"("variant_group_id");
+
+-- CreateIndex
+CREATE INDEX "catalog_entries_code_sort_id_idx" ON "catalog_entries"("code_sort", "id");
+
+-- CreateIndex
+CREATE INDEX "catalog_entries_difficulty_id_idx" ON "catalog_entries"("difficulty", "id");
+
+-- CreateIndex
+CREATE INDEX "catalog_entries_has_name_name_sort_id_idx" ON "catalog_entries"("has_name", "name_sort", "id");
+
+-- CreateIndex
+CREATE INDEX "catalog_entries_apparatus_id_idx" ON "catalog_entries"("apparatus_id");
+
+-- CreateIndex
+CREATE INDEX "catalog_entries_element_group_id_idx" ON "catalog_entries"("element_group_id");
+
+-- CreateIndex
+CREATE INDEX "catalog_words_word_idx" ON "catalog_words"("word");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "coaches_google_id_key" ON "coaches"("google_id");
+
+-- CreateIndex
+CREATE INDEX "gymnasts_coach_id_idx" ON "gymnasts"("coach_id");
+
+-- CreateIndex
+CREATE INDEX "gymnast_elements_element_id_idx" ON "gymnast_elements"("element_id");
+
+-- CreateIndex
+CREATE INDEX "gymnast_elements_vault_id_idx" ON "gymnast_elements"("vault_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "gymnast_elements_gymnast_id_element_id_key" ON "gymnast_elements"("gymnast_id", "element_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "gymnast_elements_gymnast_id_vault_id_key" ON "gymnast_elements"("gymnast_id", "vault_id");
+
+-- CreateIndex
+CREATE INDEX "routines_apparatus_id_idx" ON "routines"("apparatus_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "routines_gymnast_id_apparatus_id_name_key" ON "routines"("gymnast_id", "apparatus_id", "name");
+
+-- CreateIndex
+CREATE INDEX "routine_elements_element_id_idx" ON "routine_elements"("element_id");
+
+-- CreateIndex
+CREATE INDEX "routine_elements_vault_id_idx" ON "routine_elements"("vault_id");
+
+-- AddForeignKey
+ALTER TABLE "element_groups" ADD CONSTRAINT "element_groups_apparatus_id_fkey" FOREIGN KEY ("apparatus_id") REFERENCES "apparatus"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "elements" ADD CONSTRAINT "elements_element_group_id_fkey" FOREIGN KEY ("element_group_id") REFERENCES "element_groups"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "elements" ADD CONSTRAINT "elements_variant_group_id_fkey" FOREIGN KEY ("variant_group_id") REFERENCES "variant_groups"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "vaults" ADD CONSTRAINT "vaults_element_group_id_fkey" FOREIGN KEY ("element_group_id") REFERENCES "element_groups"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "vaults" ADD CONSTRAINT "vaults_variant_group_id_fkey" FOREIGN KEY ("variant_group_id") REFERENCES "variant_groups"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "catalog_words" ADD CONSTRAINT "catalog_words_entry_id_fkey" FOREIGN KEY ("entry_id") REFERENCES "catalog_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "gymnasts" ADD CONSTRAINT "gymnasts_coach_id_fkey" FOREIGN KEY ("coach_id") REFERENCES "coaches"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "gymnast_elements" ADD CONSTRAINT "gymnast_elements_gymnast_id_fkey" FOREIGN KEY ("gymnast_id") REFERENCES "gymnasts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "gymnast_elements" ADD CONSTRAINT "gymnast_elements_element_id_fkey" FOREIGN KEY ("element_id") REFERENCES "elements"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "gymnast_elements" ADD CONSTRAINT "gymnast_elements_vault_id_fkey" FOREIGN KEY ("vault_id") REFERENCES "vaults"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "routines" ADD CONSTRAINT "routines_gymnast_id_fkey" FOREIGN KEY ("gymnast_id") REFERENCES "gymnasts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "routines" ADD CONSTRAINT "routines_apparatus_id_fkey" FOREIGN KEY ("apparatus_id") REFERENCES "apparatus"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "routine_elements" ADD CONSTRAINT "routine_elements_routine_id_fkey" FOREIGN KEY ("routine_id") REFERENCES "routines"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "routine_elements" ADD CONSTRAINT "routine_elements_element_id_fkey" FOREIGN KEY ("element_id") REFERENCES "elements"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "routine_elements" ADD CONSTRAINT "routine_elements_vault_id_fkey" FOREIGN KEY ("vault_id") REFERENCES "vaults"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- ---------------------------------------------------------------------------
+-- Added by hand: Prisma cannot express these in schema.prisma.
+-- ---------------------------------------------------------------------------
+
+-- Each row points to exactly one catalog item: an element or a vault, never
+-- both and never neither.
+ALTER TABLE "gymnast_elements"
+ ADD CONSTRAINT "gymnast_elements_one_item_check"
+ CHECK (num_nonnulls("element_id", "vault_id") = 1);
+
+ALTER TABLE "routine_elements"
+ ADD CONSTRAINT "routine_elements_one_item_check"
+ CHECK (num_nonnulls("element_id", "vault_id") = 1);
+
+-- DO NOT REMOVE. The catalog search finds words with a prefix match
+-- (Prisma startsWith, which becomes LIKE 'term%'). Our databases use the
+-- en-US collation, and with it an ordinary B-tree index cannot serve a prefix
+-- match, so every search would read the whole table. With the C collation on
+-- this column only, the index on "word" is used. All other text keeps en-US
+-- sorting, and no special database settings are needed on any machine.
+-- (text_pattern_ops on the index was tried first, but Prisma cannot read it
+-- back and wanted to recreate the index on every migrate dev.)
+-- Changing the collation rebuilds catalog_words_word_idx automatically.
+-- See "What the index needs" in docs/API.md.
+ALTER TABLE "catalog_words" ALTER COLUMN "word" TYPE TEXT COLLATE "C";
diff --git a/backend/prisma/migrations/20261015124317_add_sort_indexes_and_updated_at_default/migration.sql b/backend/prisma/migrations/20261015124317_add_sort_indexes_and_updated_at_default/migration.sql
new file mode 100644
index 0000000..941d45a
--- /dev/null
+++ b/backend/prisma/migrations/20261015124317_add_sort_indexes_and_updated_at_default/migration.sql
@@ -0,0 +1,11 @@
+-- AlterTable
+ALTER TABLE "routines" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
+
+-- CreateIndex
+CREATE INDEX "catalog_entries_has_name_desc_name_sort_id_idx" ON "catalog_entries"("has_name" DESC, "name_sort", "id");
+
+-- CreateIndex
+CREATE INDEX "catalog_entries_apparatus_id_difficulty_id_idx" ON "catalog_entries"("apparatus_id", "difficulty", "id");
+
+-- CreateIndex
+CREATE INDEX "catalog_entries_apparatus_id_code_sort_id_idx" ON "catalog_entries"("apparatus_id", "code_sort", "id");
diff --git a/backend/prisma/migrations/20261015131631_add_value_and_position_checks/migration.sql b/backend/prisma/migrations/20261015131631_add_value_and_position_checks/migration.sql
new file mode 100644
index 0000000..2a590ee
--- /dev/null
+++ b/backend/prisma/migrations/20261015131631_add_value_and_position_checks/migration.sql
@@ -0,0 +1,18 @@
+-- Added by hand: Prisma cannot express CHECK constraints in schema.prisma.
+
+-- A difficulty value is 0.1-1.0 (A-J). The extraction script and the seed
+-- check this too, but the database refuses anything outside the range.
+ALTER TABLE "elements"
+ ADD CONSTRAINT "elements_value_range_check"
+ CHECK ("value" BETWEEN 0.1 AND 1.0);
+
+-- A vault D-value is always positive.
+ALTER TABLE "vaults"
+ ADD CONSTRAINT "vaults_d_value_positive_check"
+ CHECK ("d_value" > 0);
+
+-- Positions in a routine start at 1. The backend writes them as 1, 2, 3 ...
+-- without gaps when it replaces the items (setRoutineItems).
+ALTER TABLE "routine_elements"
+ ADD CONSTRAINT "routine_elements_position_check"
+ CHECK ("position" >= 1);
diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..044d57c
--- /dev/null
+++ b/backend/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (e.g., Git)
+provider = "postgresql"
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 744b091..4d78669 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -1,5 +1,12 @@
-// Prisma schema for the backend. The models are added in a later issue.
+// Prisma schema for the backend. It follows docs/DATA_MODEL.md, where the
+// section "Storage in PostgreSQL" explains the choices.
// Docs: https://pris.ly/d/prisma-schema
+//
+// Names are camelCase here and snake_case in the database (@map, @@map).
+//
+// Not expressed here, but added in SQL in the migration: CHECK constraints on
+// gymnast_elements and routine_elements (exactly one of element_id and
+// vault_id is set).
generator client {
provider = "prisma-client"
@@ -9,3 +16,253 @@ generator client {
datasource db {
provider = "postgresql"
}
+
+// ---------------------------------------------------------------------------
+// Catalog. Read-only in the app, filled by the seed script. The ids are the
+// readable ids from data/ ("UB", "UB-2", "UB-2.305", "UB-2.305-a").
+// ---------------------------------------------------------------------------
+
+model Apparatus {
+ id String @id
+ name String
+ /// Sort order: VT 1, UB 2, BB 3, FX 4.
+ position Int @unique
+
+ elementGroups ElementGroup[]
+ routines Routine[]
+
+ @@map("apparatus")
+}
+
+model ElementGroup {
+ id String @id
+ apparatusId String @map("apparatus_id")
+ number Int
+ name String
+
+ apparatus Apparatus @relation(fields: [apparatusId], references: [id], onDelete: Restrict)
+ elements Element[]
+ vaults Vault[]
+
+ @@unique([apparatusId, number])
+ @@map("element_groups")
+}
+
+/// Links the variants of one code. The elements and vaults point to it.
+model VariantGroup {
+ id String @id
+
+ elements Element[]
+ vaults Vault[]
+
+ @@map("variant_groups")
+}
+
+/// An element on uneven bars, balance beam or floor exercise.
+model Element {
+ id String @id
+ elementGroupId String @map("element_group_id")
+ variantGroupId String? @map("variant_group_id")
+ code String
+ /// Difficulty value 0.1-1.0. Decimal, so sums have no rounding errors.
+ value Decimal @db.Decimal(3, 1)
+ description String
+ name String?
+ image String
+ /// Set when the element is no longer in the Code of Points (soft delete).
+ removedAt DateTime? @map("removed_at") @db.Timestamptz(3)
+
+ elementGroup ElementGroup @relation(fields: [elementGroupId], references: [id], onDelete: Restrict)
+ variantGroup VariantGroup? @relation(fields: [variantGroupId], references: [id], onDelete: Restrict)
+ gymnastElements GymnastElement[]
+ routineElements RoutineElement[]
+
+ @@index([elementGroupId])
+ @@index([variantGroupId])
+ @@map("elements")
+}
+
+/// A vault. Its own table, because it has a D-value instead of a difficulty value.
+model Vault {
+ id String @id
+ elementGroupId String @map("element_group_id")
+ variantGroupId String? @map("variant_group_id")
+ code String
+ /// Fixed difficulty score, e.g. 2.6.
+ dValue Decimal @map("d_value") @db.Decimal(3, 1)
+ description String
+ name String?
+ image String
+ removedAt DateTime? @map("removed_at") @db.Timestamptz(3)
+
+ elementGroup ElementGroup @relation(fields: [elementGroupId], references: [id], onDelete: Restrict)
+ variantGroup VariantGroup? @relation(fields: [variantGroupId], references: [id], onDelete: Restrict)
+ gymnastElements GymnastElement[]
+ routineElements RoutineElement[]
+
+ @@index([elementGroupId])
+ @@index([variantGroupId])
+ @@map("vaults")
+}
+
+// ---------------------------------------------------------------------------
+// Search. Derived from the catalog by the seed script and read-only in the
+// app. See "Search without raw SQL" in docs/API.md.
+// ---------------------------------------------------------------------------
+
+enum CatalogEntryType {
+ ELEMENT
+ VAULT
+
+ @@map("catalog_entry_type")
+}
+
+/// One row per code for the catalog list: the "-a" variant of each element or
+/// vault, with the columns they share and the sort keys.
+model CatalogEntry {
+ /// The id of the "-a" variant, e.g. "UB-1.101-a".
+ id String @id
+ type CatalogEntryType
+ code String
+ name String?
+ description String
+ /// value for an element, dValue for a vault.
+ difficulty Decimal @db.Decimal(3, 1)
+ apparatusId String @map("apparatus_id")
+ elementGroupId String @map("element_group_id")
+ /// Whether the item is named after a gymnast. Used to sort named items first.
+ hasName Boolean @map("has_name")
+ /// The name in lowercase, or "" when there is no name. Never null, so the
+ /// cursor can sort on it.
+ nameSort String @map("name_sort")
+ /// The code with each part padded to the same length, so "2.1" sorts before "10.1".
+ codeSort String @map("code_sort")
+
+ words CatalogWord[]
+
+ // One index per sort, with id last so the order is stable for the cursor.
+ @@index([codeSort, id])
+ @@index([difficulty, id])
+ // Sort by name: named items first (hasName DESC) in both directions, so
+ // ascending and descending each need their own index.
+ @@index([hasName, nameSort, id])
+ @@index([hasName(sort: Desc), nameSort, id], map: "catalog_entries_has_name_desc_name_sort_id_idx")
+ // Filter by apparatus combined with a sort, so a filtered page does not walk
+ // past the rows of the other apparatus first.
+ @@index([apparatusId, difficulty, id])
+ @@index([apparatusId, codeSort, id])
+ // Filters.
+ @@index([apparatusId])
+ @@index([elementGroupId])
+ @@map("catalog_entries")
+}
+
+/// One row per distinct word of an entry, in lowercase. Holds the words of all
+/// variants of the code, so a word that is only in variant "-b" finds the code.
+model CatalogWord {
+ entryId String @map("entry_id")
+ word String
+
+ entry CatalogEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
+
+ @@id([entryId, word])
+ // For the prefix search (startsWith). The word column has the C collation,
+ // set in SQL in the migration, so this index can serve LIKE 'term%'. See
+ // "What the index needs" in docs/API.md.
+ @@index([word])
+ @@map("catalog_words")
+}
+
+// ---------------------------------------------------------------------------
+// User data. Generated ids (cuid). Deleting a coach deletes their gymnasts,
+// and deleting a gymnast deletes their routines and element list.
+// ---------------------------------------------------------------------------
+
+model Coach {
+ id String @id @default(cuid())
+ /// Google's unique user id (the "sub" claim). Never exposed in the API.
+ googleId String @unique @map("google_id")
+ email String
+ name String
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
+
+ gymnasts Gymnast[]
+
+ @@map("coaches")
+}
+
+model Gymnast {
+ id String @id @default(cuid())
+ coachId String @map("coach_id")
+ name String
+
+ coach Coach @relation(fields: [coachId], references: [id], onDelete: Cascade)
+ elements GymnastElement[]
+ routines Routine[]
+
+ @@index([coachId])
+ @@map("gymnasts")
+}
+
+/// An element or vault the gymnast can do or is practicing. Exactly one of
+/// elementId and vaultId is set (CHECK constraint in the migration).
+model GymnastElement {
+ /// A row id is needed because element_id and vault_id can be null, so they
+ /// cannot be part of a primary key.
+ id Int @id @default(autoincrement())
+ gymnastId String @map("gymnast_id")
+ elementId String? @map("element_id")
+ vaultId String? @map("vault_id")
+
+ gymnast Gymnast @relation(fields: [gymnastId], references: [id], onDelete: Cascade)
+ element Element? @relation(fields: [elementId], references: [id], onDelete: Restrict)
+ vault Vault? @relation(fields: [vaultId], references: [id], onDelete: Restrict)
+
+ // No duplicates per gymnast. PostgreSQL treats null values as different, so
+ // each rule only applies to the rows where that column is set.
+ @@unique([gymnastId, elementId])
+ @@unique([gymnastId, vaultId])
+ @@index([elementId])
+ @@index([vaultId])
+ @@map("gymnast_elements")
+}
+
+model Routine {
+ id String @id @default(cuid())
+ gymnastId String @map("gymnast_id")
+ apparatusId String @map("apparatus_id")
+ name String
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
+ /// Set by Prisma on every update of the routine row. The items are separate
+ /// rows, so setRoutineItems must also update this in the same transaction.
+ /// The default lets inserts outside Prisma work.
+ updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
+
+ gymnast Gymnast @relation(fields: [gymnastId], references: [id], onDelete: Cascade)
+ apparatus Apparatus @relation(fields: [apparatusId], references: [id], onDelete: Restrict)
+ items RoutineElement[]
+
+ // Constraint 6: a routine name is unique per gymnast and apparatus.
+ @@unique([gymnastId, apparatusId, name])
+ @@index([apparatusId])
+ @@map("routines")
+}
+
+/// One element or vault in a routine, at a position. Exactly one of elementId
+/// and vaultId is set (CHECK constraint in the migration).
+model RoutineElement {
+ routineId String @map("routine_id")
+ /// Order in the routine, starting at 1.
+ position Int
+ elementId String? @map("element_id")
+ vaultId String? @map("vault_id")
+
+ routine Routine @relation(fields: [routineId], references: [id], onDelete: Cascade)
+ element Element? @relation(fields: [elementId], references: [id], onDelete: Restrict)
+ vault Vault? @relation(fields: [vaultId], references: [id], onDelete: Restrict)
+
+ @@id([routineId, position])
+ @@index([elementId])
+ @@index([vaultId])
+ @@map("routine_elements")
+}
diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql
index 13ad6d7..2134a1d 100644
--- a/backend/src/schema.graphql
+++ b/backend/src/schema.graphql
@@ -13,8 +13,12 @@
# gymnast and apparatus (constraint 6).
# INVALID_ITEMS The items do not fit the routine: a vault outside vault,
# an element on vault, more than one vault, or an item from
-# another apparatus (constraints 7 and 8).
-# BAD_USER_INPUT Empty name, malformed id, `first` out of range.
+# another apparatus (constraints 7 and 8). Also a removed
+# item added to a gymnast's list.
+# BAD_USER_INPUT Empty name, malformed id, `first` out of range, a
+# DIFFICULTY sort without an apparatus filter, or a
+# difficulty filter together with vault, or a difficulty
+# value that is not 0.1-1.0 in steps of 0.1.
# LIMIT_REACHED The coach has reached a limit: 200 gymnasts, 50 routines
# per gymnast, or 50 items in a routine.
#
@@ -53,6 +57,12 @@ interface CatalogItem {
"""Full URL of the drawing, ready to use as the src of an
."""
imageUrl: String!
elementGroup: ElementGroup!
+ """
+ True when the item is no longer in the Code of Points. It can still be
+ fetched by id, and it stays in routines and gymnast lists, but it is not
+ found by the search and does not count in a D-value.
+ """
+ removed: Boolean!
}
"""One element on uneven bars, balance beam or floor."""
@@ -64,11 +74,21 @@ type Element implements CatalogItem {
"""Full URL of the drawing, ready to use as the src of an
."""
imageUrl: String!
elementGroup: ElementGroup!
- """Difficulty value, 0.1-1.0."""
+ """
+ True when the item is no longer in the Code of Points. It can still be
+ fetched by id, and it stays in routines and gymnast lists, but it is not
+ found by the search and does not count in a D-value.
+ """
+ removed: Boolean!
+ """
+ Difficulty value, 0.1-1.0. The frontend shows it as a letter: 0.1 = A,
+ 0.2 = B ... 1.0 = J.
+ """
value: Float!
- """Difficulty letter A-J, calculated from value."""
- difficultyLetter: String!
- """Other ways of performing the same element (same code, same value)."""
+ """
+ Other ways of performing the same element (same code, same value).
+ Removed variants are left out.
+ """
variants: [Element!]!
}
@@ -81,14 +101,26 @@ type Vault implements CatalogItem {
"""Full URL of the drawing, ready to use as the src of an
."""
imageUrl: String!
elementGroup: ElementGroup!
+ """
+ True when the item is no longer in the Code of Points. It can still be
+ fetched by id, and it stays in routines and gymnast lists, but it is not
+ found by the search and does not count in a D-value.
+ """
+ removed: Boolean!
"""Fixed difficulty score, e.g. 2.6."""
dValue: Float!
+ """Other ways of performing the same vault. Removed variants are left out."""
variants: [Vault!]!
}
"""What the user can sort the catalog by."""
enum CatalogSortField {
CODE
+ """
+ value for elements and dValue for vaults. Only allowed together with
+ filter.apparatusId, because the two scales cannot be compared. Without it
+ the search throws BAD_USER_INPUT.
+ """
DIFFICULTY
NAME
}
@@ -106,9 +138,15 @@ input CatalogSort {
input CatalogFilter {
apparatusId: ID
elementGroupId: ID
- """Applies to value for elements and dValue for vaults."""
- minDifficulty: Float
- maxDifficulty: Float
+ """
+ Only elements with one of these difficulty values (several can be chosen),
+ e.g. [0.1, 0.2]. The frontend shows the values as letters, so choosing A
+ and B sends [0.1, 0.2]. Each value must be 0.1-1.0 in steps of 0.1,
+ otherwise BAD_USER_INPUT. Vaults have D-values and not difficulty values,
+ so they are left out when this is set, and together with apparatusId "VT"
+ it throws BAD_USER_INPUT. Empty or missing means no difficulty filter.
+ """
+ difficultyValues: [Float!]
"""Only items named after a gymnast."""
namedOnly: Boolean
}
@@ -151,15 +189,16 @@ type Query {
"""
The D-score of a list of items, for the calculator. Open to everyone and
nothing is saved. Throws INVALID_ITEMS when the items do not fit together.
- At most 50 ids, otherwise BAD_USER_INPUT.
+ Removed items do not count. At most 50 ids, otherwise BAD_USER_INPUT.
"""
calculateDScore(itemIds: [ID!]!): Float!
searchCatalog(
"""
- One word, case-insensitive. Finds items with a word that starts with the
- term, in the code, name or description. Empty or missing means no text
- filter.
+ One or more words, case-insensitive. All the words must match: an item is
+ found if, for every search word, it has a word that starts with it, in the
+ code, name or description. A trailing "s" is removed from each search
+ word. Empty or missing means no text filter.
"""
search: String
filter: CatalogFilter
@@ -191,6 +230,11 @@ type Gymnast {
by the backend.
"""
routines(apparatusId: ID): [Routine!]!
+ """
+ The elements and vaults the gymnast can do or is practicing, in the order
+ they were added. Removed items stay in the list, with removed: true.
+ """
+ elements: [CatalogItem!]!
}
type Routine {
@@ -198,9 +242,16 @@ type Routine {
name: String!
gymnast: Gymnast!
apparatus: Apparatus!
- """The elements, or the single vault, in the order of the routine."""
+ """
+ The elements, or the single vault, in the order of the routine. The same
+ element may appear more than once (it still counts once in dScore).
+ Removed items stay in the routine, with removed: true, so the UI can warn.
+ """
items: [CatalogItem!]!
- """Sum of the eight highest values, each code counted once. Calculated."""
+ """
+ Sum of the eight highest values, each code counted once. Removed items do
+ not count. Calculated.
+ """
dScore: Float!
}
@@ -236,4 +287,16 @@ type Mutation {
More than 50 ids throws LIMIT_REACHED. Throws INVALID_ITEMS.
"""
setRoutineItems(routineId: ID!, itemIds: [ID!]!): Routine!
+
+ """
+ Adds an element or vault to the gymnast's list. Adding an item that is
+ already in the list does nothing. Throws NOT_FOUND for an unknown gymnast
+ or item, and INVALID_ITEMS for a removed item.
+ """
+ addGymnastElement(gymnastId: ID!, itemId: ID!): Gymnast!
+ """
+ Removes an element or vault from the gymnast's list. Removing an item that
+ is not in the list does nothing. Throws NOT_FOUND for an unknown gymnast.
+ """
+ removeGymnastElement(gymnastId: ID!, itemId: ID!): Gymnast!
}
diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md
index 86aaea0..3047163 100644
--- a/docs/AI_USAGE.md
+++ b/docs/AI_USAGE.md
@@ -16,9 +16,12 @@
- **What Claude did:** Generated all the data in the JSON files and wrote the
README. The elements, vaults, descriptions, names, coach, gymnasts and
routines are invented by Claude. The apparatus and element group names were
- written by Claude from its knowledge of the Code of Points, not copied from
- the document. Claude also checked with a script that all referenced ids
- exist and that the data follows the constraints.
+ first written by Claude from its knowledge of the Code of Points, and later
+ replaced with the real names extracted from the document (`data/`). Claude
+ also checked with a script that all referenced ids exist and that the data
+ follows the constraints. When the database schema was written, Claude Code
+ converted the files to the new storage shape (lists as rows, variant groups
+ as a column on each element and vault).
- **Prompt:**
```text
@@ -43,6 +46,28 @@
DATA_MODEL.md.
```
+## Extraction of the catalog data (`scripts/extract-cop/`, `data/`, `docs/EXTRACTION.md`)
+
+- **Tool:** Claude Code
+- **What the team did:** Wrote the task description: which files to produce,
+ how the tables in the Code of Points are built, how variants and images
+ should be handled, and which checks to run. Had the work done step by step
+ (one page of uneven bars, a page with harder cases, a vault page, then the
+ whole document) and checked the result of each step against the PDF.
+ Decided that every id keeps the variant letter (`-a`) and that images are
+ stored in one folder per apparatus. Went through every code the script
+ flagged as uncertain, and decided the corrections: 13 new descriptions,
+ names moved to the right variant, three descriptions written out in full,
+ and two codes where several drawings were one element.
+- **What Claude did:** Wrote the extraction script, the review page and the
+ checks, found the layout rules of the PDF (drawings as vector graphics or
+ bitmaps, notes inside the table, drawings in several parts), and suggested
+ texts for many of the corrections, which the team accepted or changed. Also
+ wrote `docs/EXTRACTION.md`, the READMEs in `data/` and `scripts/extract-cop/`,
+ and the commit messages and pull request descriptions.
+- **Review:** The team compared the flagged codes with the PDF and checked a
+ sample of the images. Codes the script did not flag were only spot-checked.
+
## GraphQL schema (`backend/src/schema.graphql` and `docs/API.md`)
- **Tool:** Claude Code
@@ -75,3 +100,33 @@
workspace, TypeScript, Prisma and the ESLint configuration for the backend,
wrote the "Database (local setup)" section in the README, and checked that
the install, lint, build and the connection to a local database work.
+
+## Database schema and migrations (`backend/prisma/`, `docs/DATA_MODEL.md`, `docs/API.md`)
+
+- **Tool:** Claude Code
+- **What the team did:** Made the design decisions before the work started and
+ wrote them in the task description: readable ids for the catalog and
+ generated ids for user data, lists stored as join tables with a CHECK rule,
+ `removed_at`, timestamps, `Decimal` values, and what happens on delete.
+ During the work the team changed the search design back to the one in
+ `docs/API.md` (`CatalogEntry` and `CatalogWord` instead of a database view),
+ chose how the prefix search uses the index (the `C` collation on the word
+ column), and decided the open questions from three rounds of peer review:
+ difficulty is only sorted within an apparatus and filtered by value, letters
+ are only shown in the frontend, removed elements stay but do not count, the
+ same element may appear twice in a routine, value ranges are checked in the
+ database, and the gymnast's element list is part of the API. The team
+ approved each reset of the local database, and the team members reviewed
+ the pull request.
+- **What Claude did:** Wrote `schema.prisma`, the three migrations (including
+ the CHECK constraints and the collation in SQL), and the changes to
+ `docs/DATA_MODEL.md`, `docs/API.md`, `schema.graphql` and the README. Tested
+ the constraints, the indexes (with `EXPLAIN` on large test data) and the
+ search query through Prisma, and checked that `prisma migrate dev` reports
+ no drift. Found that `text_pattern_ops` causes drift in Prisma and proposed
+ the column collation instead, and proposed most of the wording for the
+ answers to the review, which the team accepted or changed.
+- **Prompts:** The work was done in one long conversation. It started with a
+ task description that listed the tables, the decisions above and the
+ checks to run, and continued with the team's decisions and the review
+ comments as they came.
diff --git a/docs/API.md b/docs/API.md
index 67a81bc..3b6929c 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -16,12 +16,14 @@ GraphQL is required by the assignment, but it also suits this application:
The GraphQL schema is not a copy of the database tables. It describes what the client needs:
-| Database / data model | GraphQL |
-| ---------------------------------- | --------------------------------------------------------------- |
-| `Routine.elementIds` (list of ids) | `Routine.items` (the elements themselves, in order) |
-| `VariantGroup` | `Element.variants` and `Vault.variants` |
-| `Coach.googleId` | not exposed |
-| D-score, difficulty letter | calculated fields: `Routine.dScore`, `Element.difficultyLetter` |
+| Database / data model | GraphQL |
+| ----------------------------------------- | --------------------------------------------------- |
+| `routine_elements` (rows with a position) | `Routine.items` (the elements themselves, in order) |
+| `gymnast_elements` (rows) | `Gymnast.elements` (the elements themselves) |
+| `removedAt` (a timestamp) | `removed` (true or false) on every catalog item |
+| `VariantGroup` | `Element.variants` and `Vault.variants` |
+| `Coach.googleId` | not exposed |
+| D-score | calculated field: `Routine.dScore` |
Resolvers on the server translate between the two. Prisma is used inside the resolvers to read from PostgreSQL. The client never talks to Prisma or the database.
@@ -31,7 +33,7 @@ Resolvers on the server translate between the two. Prisma is used inside the res
- `Apparatus` and `ElementGroup` describe the structure of the Code of Points.
- `CatalogItem` is an interface with the fields that elements and vaults share (`id`, `code`, `description`, `name`, `imageUrl`, `elementGroup`).
-- `Element` and `Vault` implement it. An element has `value` and `difficultyLetter`. A vault has `dValue`. Both have `variants`.
+- `Element` and `Vault` implement it. An element has `value`. A vault has `dValue`. Both have `variants`. The difficulty letter (A–J) is not part of the API: the frontend shows it from `value`.
- `searchCatalog` searches, filters, sorts and pages over elements and vaults together.
Fields that only exist on one type are fetched with a fragment:
@@ -57,6 +59,8 @@ Fields that only exist on one type are fetched with a fragment:
- `login(googleToken)` verifies the Google token and returns a session token and the coach.
- Mutations create, rename and delete gymnasts and routines. `setRoutineItems` replaces the whole list of items in a routine, so the client sends the ids in the wanted order and the server does not need separate add, remove and move operations.
- `calculateDScore(itemIds)` is for the calculator, which works without logging in. It calculates the D-score and saves nothing.
+- `Gymnast.elements` is the gymnast's list of elements and vaults she can do or is practicing. `addGymnastElement` and `removeGymnastElement` change it one item at a time, because the coach adds items from the search one by one. Adding an item that is already in the list, or removing one that is not, does nothing.
+- The same element may appear more than once in a routine. Removed elements stay in routines and lists with `removed: true`, and do not count in the D-score (see "Removed elements" in [DATA_MODEL.md](DATA_MODEL.md)).
## Search, filter, sort and pagination
@@ -66,7 +70,7 @@ All of it happens in one query, `searchCatalog`, on the server and in the databa
query {
searchCatalog(
search: "salto"
- filter: { apparatusId: "BB", minDifficulty: 0.4 }
+ filter: { apparatusId: "BB", difficultyValues: [0.4, 0.5] }
sort: { field: DIFFICULTY, direction: DESC }
first: 20
after: ""
@@ -90,11 +94,32 @@ query {
}
```
-- **Search** is one word and is not case sensitive. It finds items with a word that starts with the term. An empty search means no text filter. The client should wait until the user stops typing (debounce) before it sends the query.
-- **Filter** by apparatus, group, difficulty range, and whether the item is named after a gymnast.
-- **Sort** by code, difficulty or name, ascending or descending. `DIFFICULTY` means `value` for elements and `dValue` for vaults.
+- **Search** is one or more words and is not case sensitive. It finds items that have, for every search word, a word that starts with it. An empty search means no text filter. The client should wait until the user stops typing (debounce) before it sends the query.
+- **Filter** by apparatus, group, difficulty values, and whether the item is named after a gymnast. The difficulty filter is a multiselect of values, for example `[0.4, 0.5]`. The interface shows the values as letters, so choosing D and E sends `[0.4, 0.5]`. Each value must be 0.1–1.0 in steps of 0.1. The filter only applies to elements, because vaults have D-values and not difficulty values, so vaults are left out when it is set. Together with vault it gives `BAD_USER_INPUT`.
+- **Sort** by code, difficulty or name, ascending or descending. `DIFFICULTY` means `value` for elements and `dValue` for vaults, and is only sorted within one apparatus: it is only allowed together with an `apparatusId` filter, and gives `BAD_USER_INPUT` without it. Element values (0.1–1.0) and vault D-values (about 2.0–6.4) are different scales, so one list sorted by both would always put the vaults at one end.
- **Pagination** uses cursors (the Relay connection pattern: `edges`, `node`, `cursor`, `pageInfo`). `totalCount` is the number of matches in the whole result set.
+### Why difficulty is only sorted within an apparatus
+
+Sorting by difficulty is only allowed when an apparatus is selected. Comparing difficulty across apparatus gives the user nothing of value: element values go from 0.1 to 1.0, and vault D-values from about 2.0 to 6.4, so a list sorted by both would only put all the vaults at one end. Within one apparatus the order is meaningful, so that is the only place the sort is offered. Without an apparatus filter the API gives `BAD_USER_INPUT`, and the interface should only offer the difficulty sort once an apparatus is chosen.
+
+For the same reason, the difficulty filter is a list of chosen values and not a range. A coach thinks in letters (A–J), so the interface shows the values as letters, and the API works with the values. Choosing A and B sends `[0.1, 0.2]`. The letters only exist for elements on bars, beam and floor, so the filter leaves out vaults, and cannot be combined with vault. The letter is never stored or sent by the API: the frontend calculates it from the value.
+
+### Difficulty letters are only handled in the frontend
+
+The difficulty letter (A–J) and the difficulty value (0.1–1.0) always match one to one, so only the value is stored and sent through the API. All logic in the backend and the database uses the value: filtering, sorting and the D-value of a routine. The letter is only something the user sees, so the frontend handles it in both directions:
+
+| Letter | A | B | C | D | E | F | G | H | I | J |
+| ------ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| Value | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1.0 |
+
+- **Showing an element:** the frontend gets `value` and shows the letter for it, for example `0.4` as D.
+- **Filtering:** the user chooses letters in a multiselect, and the frontend sends the matching values in `difficultyValues`. Choosing A and B sends `[0.1, 0.2]`, and the API returns the elements with value 0.1 or 0.2.
+- **Sorting:** the user sorts by difficulty, and the API sorts by value. Letters and values have the same order, so the result is the same.
+- **Vaults** have no letter. The frontend shows their D-value, for example 2.6, and does not offer the letter filter when vault is chosen.
+
+The mapping lives in one place in the frontend (a small function and the list of letters), so the API and the database never need to know about letters.
+
### Why cursors and not page numbers
The requirement is that the solution works with any amount of data. With offset pagination the database has to count past all earlier rows for every page, and rows can be skipped or shown twice if data changes between two requests. A cursor says "continue after this row", so the cost does not grow with the page number, and it fits infinite scroll. The cursor is opaque to the client, which only passes the `endCursor` back as `after`.
@@ -108,16 +133,16 @@ The requirement is that the solution works with any amount of data. The catalog
- **Every list is paged with cursors**, and the page size has an upper limit of 100 in the schema documentation (`first`: 1–100). A client cannot ask for the whole catalog in one request.
- **A page costs the same on page 1 and page 10 000.** The cursor holds the sort values and the `id` of the last row, and the database continues after it with an index. Offset pagination would read and throw away all earlier rows. We check this with `EXPLAIN ANALYZE` (see Search without raw SQL).
- **Sorting must be stable.** Many items have the same difficulty, so `id` must always be the last sort key. Without it rows can be skipped or repeated between two pages.
-- **Filter and sort need indexes.** `schema.prisma` declares an index for each sort field combined with `id` (`codeSort`, `difficulty`, `hasName` with `nameSort`), and for the filter columns (apparatus, group). Without them the database sorts the whole catalog for every request.
+- **Filter and sort need indexes.** `schema.prisma` declares an index for each sort field combined with `id` (`codeSort`, `difficulty`, and `hasName` with `nameSort`), and for the filter columns (apparatus, group). Without them the database sorts the whole catalog for every request. The name sort puts named items first in both directions (`hasName` descending), so ascending and descending each have their own index. The apparatus filter is often combined with a sort, so there are also indexes on apparatus with `difficulty` and with `codeSort`. The difficulty sort always has an apparatus filter, so it uses the index on apparatus and `difficulty`. The difficulty filter without an apparatus uses the index on `difficulty`. Without them, a filtered page walks past the rows of the other apparatus first, which is slow in one direction (for example vault sorted by ascending difficulty).
- **Sort by name needs a rule for items without a name.** Most items have no name. A cursor does not accept `nulls` placement, so the sort uses a plain `hasName` field and a `nameSort` field that is never `null` (see Search without raw SQL).
- **The text search must not scan the table.** A search for `%word%` cannot use an ordinary index. The search uses a word table with a B-tree index and a prefix match, which finds the rows that have a word starting with the search term without reading every row. A search that is correct but reads every row works on 500 rows and not on 5 million.
-- **`totalCount` is only calculated when the client asks for it.** Counting a large result takes time. The resolver for the field runs only if `totalCount` is in the query, so a client that scrolls without showing a total does not pay for it.
+- **`totalCount` is only calculated when the client asks for it.** Counting a large result takes time: about 30 ms for the whole catalog and about 100 ms for a search in a test with 225 000 entries, and the cost grows with the catalog. The resolver for the field runs only if `totalCount` is in the query, so a client that scrolls without showing a total does not pay for it. The client should therefore ask for `totalCount` only with the first page of a new search or filter, and not with the pages that are loaded while scrolling. Together with the debounce on the search field, this keeps the count from running on every keystroke. If the count is still too slow, it can be capped (for example "1000+"), which needs a change in the schema.
- **Elements and vaults are different tables, but one list.** A query cannot combine two tables into one sorted and paged result. The data model keeps them apart because they work differently, so the list is read from `CatalogEntry`, a read-only table with the shared columns that the seed script fills. It never gets out of date because the catalog never changes between seed runs.
- **Variants are few.** A variant group has two or three members, so `variants` does not need paging.
### 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`, `Routine.items` and `Routine.apparatus`.
+- **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.
- **Limit the size of the arguments.** `calculateDScore` and `setRoutineItems` take at most 50 ids.
@@ -156,14 +181,14 @@ The catalog, `searchCatalog` and `calculateDScore` do not need a login. Everythi
Errors are thrown from the resolvers as GraphQL errors with a machine-readable code in `extensions.code`. The client reacts to the code and never to the text of the message.
-| Code | When |
-| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `UNAUTHENTICATED` | The operation needs a logged-in coach. |
-| `NOT_FOUND` | The id does not exist, or belongs to another coach. |
-| `NAME_TAKEN` | The gymnast already has a routine with this name on this apparatus (constraint 6). |
-| `INVALID_ITEMS` | The items do not fit the routine: a vault outside vault, an element on vault, more than one vault, or an item from another apparatus (constraints 7 and 8). |
-| `BAD_USER_INPUT` | Empty name, malformed id, or `first` out of range. |
-| `LIMIT_REACHED` | The coach has reached a limit: 200 gymnasts, 50 routines per gymnast, or 50 items in a routine. See [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). |
+| Code | When |
+| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `UNAUTHENTICATED` | The operation needs a logged-in coach. |
+| `NOT_FOUND` | The id does not exist, or belongs to another coach. |
+| `NAME_TAKEN` | The gymnast already has a routine with this name on this apparatus (constraint 6). |
+| `INVALID_ITEMS` | The items do not fit the routine: a vault outside vault, an element on vault, more than one vault, or an item from another apparatus (constraints 7 and 8). |
+| `BAD_USER_INPUT` | Empty name, malformed id, `first` out of range, a `DIFFICULTY` sort without an apparatus filter, a difficulty filter together with vault, or a difficulty value that is not 0.1–1.0 in steps of 0.1. |
+| `LIMIT_REACHED` | The coach has reached a limit: 200 gymnasts, 50 routines per gymnast, or 50 items in a routine. See [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). |
An error response looks like this:
@@ -200,11 +225,11 @@ These are the choices for the implementation. They are proposals until the backe
### Search without raw SQL
-**The backend never uses raw SQL.** No `$queryRaw`, `$executeRaw` or their `Unsafe` versions. Every query goes through Prisma Client, which sends all values as parameters. This removes SQL injection by the way the code is built, and not by careful escaping. A lint rule (`no-restricted-properties` on those names) makes the build fail if someone adds a raw query. Indexes are declared in `schema.prisma` with `@@index`, and the migrations are generated by Prisma.
+**The backend never uses raw SQL.** No `$queryRaw`, `$executeRaw` or their `Unsafe` versions. Every query goes through Prisma Client, which sends all values as parameters. This removes SQL injection by the way the code is built, and not by careful escaping. A lint rule (`no-restricted-properties` on those names) will make the build fail if someone adds a raw query. It is added together with the search (issue #24). Indexes are declared in `schema.prisma` with `@@index`, and the migrations are generated by Prisma, with a few additions written by hand in SQL: CHECK constraints and the collation of `catalog_words.word`, which Prisma cannot express. These are migrations, not queries in the backend, so they do not break the rule.
-The requirement is search on single words that is not case sensitive, together with filter, sort and cursors over the whole catalog. Prisma can do this if the data is shaped for it. Two things stop Prisma from doing it on the element and vault tables directly: a query cannot combine two tables into one sorted list, and a cursor only works when every sort field is a plain field of the model (a sort with `nulls` placement, or on a related record, makes it throw an error).
+The requirement is search on words that is not case sensitive, together with filter, sort and cursors over the whole catalog. Prisma can do this if the data is shaped for it. Two things stop Prisma from doing it on the element and vault tables directly: a query cannot combine two tables into one sorted list, and a cursor only works when every sort field is a plain field of the model (a sort with `nulls` placement, or on a related record, makes it throw an error).
-**The catalog entry model.** `CatalogEntry` is a read-only table, filled by the seed script, with one row for every element and every vault:
+**The catalog entry model.** `CatalogEntry` is a read-only table, filled by the seed script, with one row per code: the first variant that is not removed, normally the `-a` variant of each element and each vault. The catalog list shows one row per code, and the other variants are shown on the detail page:
| Field | Content |
| ------------------------------- | ------------------------------------------------------------------------------- |
@@ -220,30 +245,43 @@ The element and vault tables stay as the data model describes them and are the s
**Sort without `null`.** Most items have no name, so sorting by name cannot put `null` last, because a cursor does not accept `nulls` placement. The sort is `hasName` descending, then `nameSort`, then `id`. All three are plain fields and no value is `null`, so named items come first in both directions and the cursor works.
-**The word index.** A second table, `CatalogWord`, has one row for each distinct word of each entry: the entry id and the word in lowercase. The seed script fills it by splitting the code, the name and the description into words (letters and digits; a code like `3.304` is kept as one word). It has a B-tree index on `word`. The search is a Prisma filter:
+**The word index.** A second table, `CatalogWord`, has one row for each distinct word of each entry: the entry id and the word in lowercase. The seed script fills it by splitting the code, the name and the description of **all the variants** of the code into words (letters and digits; a code like `3.304` is kept as one word). A word that is only in variant `-b` therefore still finds the code. The seed script also adds abbreviations from the Code of Points written out as words, for example `handstand` for `hstd`. It has a B-tree index on `word`. The search is a Prisma filter with one condition per search word:
```ts
where: {
- words: {
- some: {
- word: {
- startsWith: term
- }
- }
- }
+ AND: terms.map((term) => ({
+ words: {
+ some: {
+ word: {
+ startsWith: term,
+ },
+ },
+ },
+ }))
}
```
-- The term is cleaned to lowercase letters, digits and `.` before use. This also removes `%` and `_`, which mean something in a `LIKE` pattern.
-- Both the stored words and the term are lowercase, so the search is not case sensitive without `mode: 'insensitive'`. That mode becomes `ILIKE`, which an ordinary index cannot serve.
-- `startsWith` finds the rows that have a word starting with the term, so "sal" finds "salto". It works from the first letter.
+- The search text is split into words on spaces. Each word is cleaned to lowercase letters, digits and `.` before use. Empty words are dropped.
+- **The cleaning is the only protection against wildcards.** Prisma does not escape `%` and `_` in `startsWith`: a search for `%` becomes `LIKE '%%'` and matches every word (in a test, all 225 000 entries in about 460 ms). The cleaning removes them, so it must always run before the query, and it must have tests.
+- **The cleaning is also what makes the search case-insensitive.** The words are stored in lowercase, so an uppercase term such as "SAL" finds nothing unless it is lowercased first.
+- **All words must match.** An item is found only if it has, for every search word, a word that starts with it. "giant turn" finds giant circles with a turn, and not every giant circle and every turn.
+- **A trailing `s` is removed from each search word**, so "saltos" finds "salto" and "hips" finds "hip". Because the match is on the start of a word, the shorter word still finds the longer one: "salto" also finds "saltos". A search word that is only `s` is dropped.
+- Both the stored words and the term are lowercase, so the search is not case sensitive without `mode: 'insensitive'`. **The search query must never use `mode: 'insensitive'`.** It turns `LIKE` into `ILIKE`, and then the index is no longer used. The seed script stores the words in lowercase, and the backend lowercases the search term before the query.
+- `startsWith` finds the rows that have a word starting with the search word, so "sal" finds "salto". It works from the first letter.
- The filter, sort and cursor use the same `findMany` call, and `totalCount` uses `count` with the same `where`.
-**What the index needs.** A B-tree index can serve `LIKE 'sal%'` only if the database uses the `C` collation, or the index uses `text_pattern_ops`. The simple way is to create the database with the `C` collation (a setting when the database is created on the virtual machine, which goes in the setup instructions). This must be checked, not assumed (see below).
+**What the index needs.** A B-tree index can serve `LIKE 'sal%'` only if the column uses the `C` collation, or the index uses `text_pattern_ops`. Our databases use the `en-US` collation (the default for PostgreSQL 18 on our machines), so an ordinary index on `word` is not used for a prefix search, and every search would read the whole table. We checked this with `EXPLAIN`.
+
+We therefore set the `C` collation on the `word` column of `CatalogWord` only, in the migration (`ALTER TABLE "catalog_words" ALTER COLUMN "word" TYPE TEXT COLLATE "C"`), and keep an ordinary `@@index([word])` in `schema.prisma`. The reasons:
+
+- **It only affects this column.** All other text keeps the `en-US` sorting, including `nameSort` for names with accents. Creating the whole database with the `C` collation would change the sorting of all text.
+- **It works the same everywhere.** The setting is part of the migration, so it is the same on every machine and on the virtual machine, without any special database settings. With a database-wide `C` collation, anyone who created the database with the default settings would silently get a slow search.
+
+We tried `text_pattern_ops` on the index first. Prisma accepts it in `schema.prisma`, but it cannot read it back from the database, so it wanted to drop and recreate the index on every `prisma migrate dev`.
**What it costs.**
-- It does not find the middle of a word ("alto" does not find "salto"), and it does not forgive spelling mistakes. This is what the requirement asks for (single words), and a search engine could add more later.
+- It does not find the middle of a word ("alto" does not find "salto"), and it does not forgive spelling mistakes, apart from a trailing `s`. A search engine could add more later.
- There is no ordering by relevance. The user sorts by code, difficulty or name, which all have indexes and give a stable cursor.
- The tables hold the same words again, so the database is larger. The catalog is read-only and small compared to the cost of a slow search, and the seed script keeps the tables in step with the catalog. If the catalog changes, the seed script runs again.
@@ -253,7 +291,9 @@ where: {
**The cursor.** The cursor given to the client is an opaque text that holds the sort field, the values of the sort fields for the last row, and its `id`. The server decodes and checks it before Prisma gets it. A cursor that cannot be decoded, or that was made for another sort field, gives `BAD_USER_INPUT`.
-**The database user is limited.** The backend connects with a user that can read and write the tables of the application, and cannot create users or databases. Migrations are run with a different user.
+**Query plans.** Prisma sends the search as `word::text LIKE ($1 || '%')`. PostgreSQL normally plans each query with the actual value, sees the prefix, and uses the index (`word >= 'sal' AND word < 'sam'`, about 0.03 ms). With a generic plan, made without knowing the value, it cannot use the prefix and reads the whole table (86 ms on 3 million words in a test). Do not set `plan_cache_mode = force_generic_plan`. Be careful with a connection pooler that uses named prepared statements, because PostgreSQL can switch a prepared statement to a generic plan after it has run a few times. Check with `EXPLAIN` that the index is still used if the connection setup changes.
+
+**The database user is limited.** On the virtual machine, the backend connects with a user that can read and write the tables of the application, and cannot create users or databases. Migrations are run with a different user. On our own machines we use one user, `cop`, for everything, because it needs to create the shadow database for `prisma migrate dev` (see "Database (local setup)" in the README).
The alternative for much larger systems is a search engine such as Meilisearch or Elasticsearch. It would be a second service on the virtual machine to install, start and keep in step with the database, and the assignment asks for the database on the virtual machine. For a catalog of this size it adds work and no benefit, so we do not use it.
@@ -283,8 +323,14 @@ Vitest runs the API tests against a test database, with queries sent to the serv
- **Pagination:** every item is returned once when we follow the cursors to the end. This must also hold when many items have the same sort value, and for items without a name when sorting by name. `hasNextPage` is `false` on the last page.
- **Filter and sort:** done over the whole catalog, not only the loaded page.
- **Errors:** each code in the table is returned for the right case. A coach that asks for another coach's gymnast gets `NOT_FOUND`, and a request without a login gets `UNAUTHENTICATED` (but `me` gives `null`).
+- **Wildcards and case:** a search for `%`, `_` or a word containing them does not match everything, and an uppercase search such as "SAL" finds the same items as "sal".
- **Injection:** a search term, an `apparatusId` and a cursor that contain SQL (for example a quote followed by DROP TABLE) are handled as plain values and give no results or `BAD_USER_INPUT`, and the tables are still there afterwards. A cursor made for another sort field is rejected. The lint rule against raw queries passes.
- **Constraints 6–8:** a routine name that exists, a vault in a beam routine, more than one vault, and an item from the wrong apparatus.
+- **The gymnast's list:** adding the same item twice keeps one row, removing an item that is not there does nothing, and adding a removed item gives `INVALID_ITEMS`.
+- **Removed elements:** a removed item is still returned by id with `removed: true`, is left out of `variants` and the search, and does not count in `dScore`. A code whose `-a` is removed is still found through its `-b`.
+- **Duplicates in a routine:** the same element twice is accepted and counts once in `dScore`.
+- **Difficulty sort:** without an apparatus filter it gives `BAD_USER_INPUT`, and with one it only returns items from that apparatus.
+- **Difficulty values:** choosing `[0.1, 0.2]` returns elements with value 0.1 or 0.2, vaults are left out, a value such as 0.15 or 1.1 gives `BAD_USER_INPUT`, and the filter together with vault gives `BAD_USER_INPUT`.
- **Limits:** `first` of 0 and 101, more than 50 ids, the 201st gymnast, and a query that is too deep.
- **The D-score:** the eight highest values, each code counted once, and the single vault on vault.
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
index f863b1d..13476d3 100644
--- a/docs/DATA_MODEL.md
+++ b/docs/DATA_MODEL.md
@@ -2,12 +2,14 @@
This document describes the data model for the Code of Points catalog: what each entity holds, how the entities relate to each other, the constraints we enforce, and the reasoning behind the main design choices.
-The catalog data (apparatus, element groups, elements, vaults and variant groups) is extracted from the FIG Women's Artistic Gymnastics Code of Points 2025–2028 and is read-only in the app. The user data (coaches, gymnasts and routines) is created by users through the website.
+The catalog data (apparatus, element groups, elements, vaults and variant groups) is extracted from the FIG Women's Artistic Gymnastics Code of Points 2025–2028 and is read-only in the app. The user data (coaches, gymnasts and routines) is created by users through the website. The app is built on the Code of Points for women's artistic gymnastics, so all gymnasts are women, and the documentation refers to a gymnast as "she".
Example data that shows how each entity is structured is in [examples/](examples/). The entries are invented and are for documentation only.
The extracted catalog data is in [data/](../data/). [EXTRACTION.md](EXTRACTION.md) explains how it was made.
+The model is stored in PostgreSQL through Prisma. The schema is in [`backend/prisma/schema.prisma`](../backend/prisma/schema.prisma), and the section [Storage in PostgreSQL](#storage-in-postgresql) explains how the model is turned into tables.
+
## Diagram
```mermaid
@@ -20,19 +22,23 @@ erDiagram
COACH ||--o{ GYMNAST : coaches
GYMNAST ||--o{ ROUTINE : has
APPARATUS ||--o{ ROUTINE : "performed on"
- ROUTINE }o--o{ ELEMENT : includes
- ROUTINE }o--o{ VAULT : includes
- GYMNAST }o--o{ ELEMENT : "can do or practices"
- GYMNAST }o--o{ VAULT : "can do or practices"
-
- APPARATUS { string id string name }
+ GYMNAST ||--o{ GYMNAST_ELEMENT : "can do or practices"
+ ELEMENT |o--o{ GYMNAST_ELEMENT : "is in"
+ VAULT |o--o{ GYMNAST_ELEMENT : "is in"
+ ROUTINE ||--o{ ROUTINE_ELEMENT : "consists of"
+ ELEMENT |o--o{ ROUTINE_ELEMENT : "is in"
+ VAULT |o--o{ ROUTINE_ELEMENT : "is in"
+
+ APPARATUS { string id string name int position }
ELEMENT_GROUP { string id string apparatusId int number string name }
- ELEMENT { string id string elementGroupId string code float value string description string name string image }
- VAULT { string id string elementGroupId string code float dValue string description string name string image }
- VARIANT_GROUP { string id string_array variantIds }
+ VARIANT_GROUP { string id }
+ ELEMENT { string id string elementGroupId string variantGroupId decimal value string code string description string name string image datetime removedAt }
+ VAULT { string id string elementGroupId string variantGroupId decimal dValue string code string description string name string image datetime removedAt }
COACH { string id string googleId string email string name datetime createdAt }
- GYMNAST { string id string coachId string name string_array elementIds }
- ROUTINE { string id string gymnastId string name string apparatusId string_array elementIds }
+ GYMNAST { string id string coachId string name }
+ GYMNAST_ELEMENT { int id string gymnastId string elementId string vaultId }
+ ROUTINE { string id string gymnastId string apparatusId string name datetime createdAt datetime updatedAt }
+ ROUTINE_ELEMENT { string routineId int position string elementId string vaultId }
```
## Reading the diagram
@@ -41,7 +47,7 @@ The model has two parts.
**The catalog** Each of the four apparatus (vault, uneven bars, balance beam, floor) is divided into element groups, matching the groups in the Code of Points, for example "1.000 Mounts" on uneven bars. Each group contains either elements (uneven bars, beam, floor) or vaults. Some elements and vaults can be performed in more than one way. These variants are linked together by a variant group.
-**The user data** A coach logs in and creates gymnasts. Each gymnast has a list of elements she can do or is practicing, and can have several routines on each apparatus, so the coach can compare different versions and their D-values. A routine is an ordered list of ids: elements on uneven bars, beam and floor, and a single vault on vault.
+**The user data** A coach logs in and creates gymnasts. Each gymnast has a list of elements she can do or is practicing, and can have several routines on each apparatus, so the coach can compare different versions and their D-values. A routine is an ordered list of items: elements on uneven bars, beam and floor, and a single vault on vault. Both lists are stored as rows in their own tables (`GymnastElement` and `RoutineElement`), where each row points to either an element or a vault.
## Entities
@@ -49,10 +55,11 @@ The model has two parts.
The four apparatus in women's artistic gymnastics.
-| Field | Example | Description |
-| ------ | --------------- | ---------------------------------- |
-| `id` | `"UB"` | `VT`, `UB`, `BB` or `FX` |
-| `name` | `"Uneven bars"` | Name as used in the Code of Points |
+| Field | Example | Description |
+| ---------- | --------------- | ------------------------------------------ |
+| `id` | `"UB"` | `VT`, `UB`, `BB` or `FX` |
+| `name` | `"Uneven bars"` | Name as used in the Code of Points |
+| `position` | `2` | Sort order: VT 1, UB 2, BB 3, FX 4. Unique |
### ElementGroup
@@ -73,34 +80,37 @@ One element on uneven bars, balance beam or floor.
| ---------------- | ------------------------------ | -------------------------------------------------------------------------------------------------- |
| `id` | `"UB-1.101-a"` | Apparatus + code + variant letter. The letter is always there, also when there is only one variant |
| `elementGroupId` | `"UB-1"` | The group the element belongs to |
+| `variantGroupId` | `"UB-1.101"` or `null` | The variant group, if the code has more than one variant |
| `code` | `"1.101"` | Element number in the Code of Points |
-| `value` | `0.1` | Difficulty value (0.1–1.0) |
+| `value` | `0.1` | Difficulty value (0.1–1.0), stored as a decimal number |
| `description` | `"Glide kip to support on LB"` | Description from the Code of Points |
| `name` | `"Biles"` or `null` | The gymnast the element is named after, if any |
| `image` | `"UB-1.101-a.webp"` | File name of the drawing. The file is in a folder per apparatus: `data/images/UB/UB-1.101-a.webp` |
+| `removedAt` | `null` | When the element was removed from the Code of Points, or `null` if it is still there |
### Vault
One vault from the vault table.
-| Field | Example | Description |
-| ---------------- | ------------------ | ------------------------------------------------------------------------------------------------ |
-| `id` | `"VT-1.22-a"` | `VT` + code + variant letter. The letter is always there, also when there is only one variant |
-| `elementGroupId` | `"VT-1"` | The vault group the vault belongs to |
-| `code` | `"1.22"` | Vault number in the Code of Points |
-| `dValue` | `2.6` | Difficulty score for the vault |
-| `description` | `"..."` | Description from the Code of Points |
-| `name` | `"Kim"` or `null` | The gymnast the vault is named after, if any |
-| `image` | `"VT-1.22-a.webp"` | File name of the drawing. The file is in a folder per apparatus: `data/images/VT/VT-1.22-a.webp` |
+| Field | Example | Description |
+| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------ |
+| `id` | `"VT-1.22-a"` | `VT` + code + variant letter. The letter is always there, also when there is only one variant |
+| `elementGroupId` | `"VT-1"` | The vault group the vault belongs to |
+| `variantGroupId` | `"VT-2.11"` or `null` | The variant group, if the code has more than one variant |
+| `code` | `"1.22"` | Vault number in the Code of Points |
+| `dValue` | `2.6` | Difficulty score for the vault, stored as a decimal number |
+| `description` | `"..."` | Description from the Code of Points |
+| `name` | `"Kim"` or `null` | The gymnast the vault is named after, if any |
+| `image` | `"VT-1.22-a.webp"` | File name of the drawing. The file is in a folder per apparatus: `data/images/VT/VT-1.22-a.webp` |
+| `removedAt` | `null` | When the vault was removed from the Code of Points, or `null` if it is still there |
### VariantGroup
-Links elements or vaults that are different ways of performing the same element. Only elements and vaults with more than one variant belong to a variant group.
+Links elements or vaults that are different ways of performing the same element. Only codes with more than one variant have a variant group. The group itself only has an id: the elements or vaults point to it with their `variantGroupId`.
-| Field | Example | Description |
-| ------------ | ------------------------------ | ---------------------------------------------------------------------------------- |
-| `id` | `"UB-1.101"` | Apparatus + code. An id starting with `VT-` holds vaults, all others hold elements |
-| `variantIds` | `["UB-1.101-a", "UB-1.101-b"]` | The elements or vaults in the group |
+| Field | Example | Description |
+| ----- | ------------ | ---------------------------------------------------------------------------------- |
+| `id` | `"UB-1.101"` | Apparatus + code. An id starting with `VT-` holds vaults, all others hold elements |
### Coach
@@ -108,7 +118,7 @@ A user of the website. Coaches log in with Google.
| Field | Example | Description |
| ----------- | ------------------------- | ------------------------------------------------------------- |
-| `id` | | Internal id |
+| `id` | `"cmg1x2…"` | Generated id (cuid) |
| `googleId` | `"109876543210…"` | Google's unique user id (the `sub` claim in the Google token) |
| `email` | `"kari.nilsen@gmail.com"` | Email from the Google account |
| `name` | `"Kari Nilsen"` | Name from the Google account |
@@ -118,64 +128,198 @@ A user of the website. Coaches log in with Google.
A gymnast created by a coach.
-| Field | Example | Description |
-| ------------ | ------------------------------------------- | ------------------------------------------------------- |
-| `id` | | Internal id |
-| `coachId` | | The coach who created the gymnast |
-| `name` | `"Ingrid Haugen"` | Name of the gymnast |
-| `elementIds` | `["UB-1.101-a", "UB-3.304-a", "VT-1.22-a"]` | Elements and vaults the gymnast can do or is practicing |
+| Field | Example | Description |
+| --------- | ----------------- | --------------------------------- |
+| `id` | `"cmg1x3…"` | Generated id (cuid) |
+| `coachId` | | The coach who created the gymnast |
+| `name` | `"Ingrid Haugen"` | Name of the gymnast |
+
+### GymnastElement
+
+One element or vault that a gymnast can do or is practicing. A gymnast's list is all the rows with her `gymnastId`. Exactly one of `elementId` and `vaultId` is set.
+
+| Field | Example | Description |
+| ----------- | ------------------------ | ------------------------------------- |
+| `id` | `1` | Row number, generated by the database |
+| `gymnastId` | | The gymnast the row belongs to |
+| `elementId` | `"UB-1.101-a"` or `null` | The element, if the row is an element |
+| `vaultId` | `"VT-1.22-a"` or `null` | The vault, if the row is a vault |
### Routine
A gymnast's routine on one apparatus. A gymnast can have several routines on the same apparatus.
-| Field | Example | Description |
-| ------------- | ------------------------------------------------- | ------------------------------------------------------------------ |
-| `id` | | Internal id |
-| `gymnastId` | | The gymnast the routine belongs to |
-| `name` | `"Competition routine"` | Name chosen by the coach, to tell routines apart |
-| `apparatusId` | `"UB"` | The apparatus the routine is performed on |
-| `elementIds` | `["UB-1.101-a", "UB-3.304-a"]` or `["VT-1.22-a"]` | The elements in the routine, in order. On vault: exactly one vault |
+| Field | Example | Description |
+| ------------- | ------------------------ | ------------------------------------------------ |
+| `id` | `"cmg1x4…"` | Generated id (cuid) |
+| `gymnastId` | | The gymnast the routine belongs to |
+| `apparatusId` | `"UB"` | The apparatus the routine is performed on |
+| `name` | `"Competition routine"` | Name chosen by the coach, to tell routines apart |
+| `createdAt` | `"2026-10-01T12:00:00Z"` | When the routine was created |
+| `updatedAt` | `"2026-10-03T09:30:00Z"` | When the routine or its name was last changed |
+
+### RoutineElement
+
+One element or vault in a routine. The routine's items are all the rows with its `routineId`, ordered by `position`. Exactly one of `elementId` and `vaultId` is set. On vault, a routine has exactly one row, with a vault.
+
+| Field | Example | Description |
+| ----------- | ------------------------ | ---------------------------------------------------- |
+| `routineId` | | The routine the row belongs to |
+| `position` | `1` | The place in the routine, from 1. Unique per routine |
+| `elementId` | `"UB-1.101-a"` or `null` | The element, if the row is an element |
+| `vaultId` | `"VT-1.22-a"` or `null` | The vault, if the row is a vault |
### Calculator routine (not stored)
-The calculator lets anyone put together a routine and see its D-value, without logging in. It has the same shape as a routine, but without an id or a gymnast, and it is never stored in the database. It lives in the client's state while the user is on the page.
+The calculator lets anyone put together a routine and see its D-value, without logging in. It has the same shape as a routine, but without an id, a gymnast or a name, and it is never stored in the database. It lives in the client's state while the user is on the page.
-| Field | Example | Description |
-| ------------- | ------------------------------ | ------------------------------------------------------------------ |
-| `apparatusId` | `"UB"` | The apparatus the routine is for |
-| `elementIds` | `["UB-1.101-a", "UB-3.304-a"]` | The elements in the routine, in order. On vault: exactly one vault |
+| Field | Example | Description |
+| ------------- | ------------------------------ | ---------------------------------------------------------------------------- |
+| `apparatusId` | `"UB"` | The apparatus the user has chosen in the calculator. Only kept in the client |
+| `itemIds` | `["UB-1.101-a", "UB-3.304-a"]` | The elements in the routine, in order. On vault: exactly one vault |
-The D-value is calculated by the backend through a query that takes `apparatusId` and `elementIds` and returns the D-value without saving anything. That way the calculator and saved routines use the same calculation.
+The D-value is calculated by the backend through the query `calculateDScore(itemIds)`, which returns the D-value without saving anything. It only takes the ids: the apparatus follows from the items (vault ids start with `VT-`, and every other id starts with its apparatus), and items that do not fit together give `INVALID_ITEMS`. That way the calculator and saved routines use the same calculation and the same checks.
A logged-in coach can save a calculator routine as a routine for one of their gymnasts. It then gets an `id`, a `gymnastId` and a `name`, and is stored like any other routine.
+### Search tables
+
+The two search tables are not in the diagram at the top, because they hold no new information. Together they look like this:
+
+```mermaid
+erDiagram
+ CATALOG_ENTRY ||--o{ CATALOG_WORD : "is found by"
+
+ CATALOG_ENTRY { string id string type string code string name string description decimal difficulty string apparatusId string elementGroupId boolean hasName string nameSort string codeSort }
+ CATALOG_WORD { string entryId string word }
+```
+
+### CatalogEntry
+
+A read-only table for the catalog search, filled by the seed script from the elements and vaults. It is not in the diagram, because it holds no new information. There is one row per code: the first variant of each element or vault that is not removed, normally the `-a` variant. If `-a` is removed but `-b` is still in the Code of Points, the row uses `-b`, so the code does not disappear from the catalog. A code where every variant is removed is left out. The other variants are shown on the detail page.
+
+`CatalogEntry` and `CatalogWord` have no foreign keys to the elements, vaults or groups, so the database does not stop a row that points to nothing. The seed script therefore rebuilds both tables from the catalog in one transaction every time it runs, so they always match the catalog.
+
+| Field | Example | Description |
+| ---------------- | ------------------------------ | ------------------------------------------------------------------------------- |
+| `id` | `"UB-1.101-a"` | The id of the `-a` variant |
+| `type` | `ELEMENT` | `ELEMENT` or `VAULT` |
+| `code` | `"1.101"` | Copied from the element or vault |
+| `name` | `"Biles"` or `null` | Copied from the element or vault |
+| `description` | `"Glide kip to support on LB"` | Copied from the element or vault |
+| `difficulty` | `0.1` | `value` for an element and `dValue` for a vault |
+| `apparatusId` | `"UB"` | For the apparatus filter |
+| `elementGroupId` | `"UB-1"` | For the group filter |
+| `hasName` | `true` | Whether the item is named after a gymnast |
+| `nameSort` | `"biles"` or `""` | The name in lowercase, or empty. Never `null`, so it can be used with a cursor |
+| `codeSort` | `"0001.0101"` | The code with each part padded to the same length, so `2.1` sorts before `10.1` |
+
+### CatalogWord
+
+The words the catalog search looks in, one row per distinct word of an entry. Read-only and filled by the seed script.
+
+| Field | Example | Description |
+| --------- | -------------- | ----------------------------------------------------------------------------------------------- |
+| `entryId` | `"UB-1.101-a"` | The catalog entry the word finds |
+| `word` | `"glide"` | One word in lowercase. The column has the `C` collation, so the index can serve a prefix search |
+
+The words come from the code, the name and the description of **all** variants of the code, so a word that is only in variant `-b` still finds the code. The seed script also adds abbreviations from the Code of Points written out as words, for example `handstand` for `hstd`, so that a search for the full word finds elements whose description uses the abbreviation.
+
## Constraints
### Catalog data
These are checked by the extraction script when the data is generated.
-1. Every variant group has at least two ids, and every id exists.
-2. All ids in a variant group are of the same type: only elements or only vaults.
-3. An element or vault belongs to at most one variant group.
+1. Every variant group has at least two members, and every element or vault that points to a variant group exists.
+2. The members of a variant group are of the same type: only elements or only vaults.
+3. An element or vault belongs to at most one variant group. The database guarantees this, because `variantGroupId` holds one value.
4. Variants in the same group have the same `code` and the same value (`value` or `dValue`).
5. Every element and vault has an image. Any that are missing are flagged for manual review.
### User data
-These are enforced by the backend.
+Some of these are enforced by the database and some by the backend, as noted for each.
-6. A gymnast can have any number of routines on each apparatus. Routine names should be unique per gymnast and apparatus, so the coach can tell them apart.
-7. A routine on vault has exactly one id in `elementIds`, and it is a vault. A routine on any other apparatus contains only elements.
-8. All elements in a routine must belong to the same apparatus as the routine.
-9. An element appears at most once in a gymnast's `elementIds`.
-10. The catalog is open to everyone. Gymnasts and routines can only be read and changed by the coach they belong to.
+6. A gymnast can have any number of routines on each apparatus, but routine names are unique per gymnast and apparatus, so the coach can tell them apart. _Database: unique constraint._
+7. A routine on vault has exactly one item, and it is a vault. A routine on any other apparatus contains only elements. _Backend._
+8. All elements in a routine must belong to the same apparatus as the routine. _Backend._
+9. The catalog is open to everyone. Gymnasts and routines can only be read and changed by the coach they belong to. _Backend._
+10. An element or vault appears at most once in a gymnast's list. _Database: unique constraints._
+11. Every row in a gymnast's list or in a routine points to exactly one catalog item: an element or a vault, never both and never neither. _Database: CHECK constraint._
+
+Constraints 7 and 8 compare several rows, or a row with its routine, so they cannot be written as a simple constraint in the database.
### Calculated values
-11. The difficulty letter (A–J) is not stored. It is calculated from `value`: 0.1 = A, 0.2 = B … 1.0 = J.
-12. The D-value of a routine is calculated, not stored. It is the sum of the eight highest values in the routine (`value` for elements, `dValue` for vaults), where each `code` is counted only once. Two variants of the same element therefore only count once, as in the Code of Points. On vault the routine holds one vault, so the D-value is that vault's `dValue`. Composition requirements and connection values are not included yet.
+12. The difficulty letter (A–J) is not stored, and not part of the API. The frontend calculates it from `value`: 0.1 = A, 0.2 = B … 1.0 = J.
+13. The D-value of a routine is calculated, not stored. It is the sum of the eight highest values in the routine (`value` for elements, `dValue` for vaults), where each `code` is counted only once. Two variants of the same element therefore only count once, as in the Code of Points. On vault the routine holds one vault, so the D-value is that vault's `dValue`. Removed elements and vaults do not count. Composition requirements and connection values are not included yet.
+
+## Storage in PostgreSQL
+
+The schema is in [`backend/prisma/schema.prisma`](../backend/prisma/schema.prisma), and the tables are created by the migrations in `backend/prisma/migrations/`. The names are camelCase in Prisma and in this document, and snake_case in the database: the field `elementGroupId` is the column `element_group_id`, and the model `RoutineElement` is the table `routine_elements`.
+
+### Lists become rows
+
+The data model has three lists: the members of a variant group, a gymnast's elements and the items of a routine. A relational database does not store lists in a column, so each list is stored as rows that point to their owner:
+
+- **Variant groups:** each element and vault has a `variantGroupId`, which is `null` when the code has only one variant. The members of a group are the rows that point to it.
+- **A gymnast's list:** the table `gymnast_elements`, with one row per element or vault.
+- **A routine:** the table `routine_elements`, with one row per item and a `position` that gives the order. The position starts at 1 and is unique per routine. The same element may appear more than once in a routine, at different positions. It still counts only once in the D-value, but connection bonuses, which are planned for later, can involve the same element twice (for example C + C).
+
+A gymnast's list and a routine can hold both elements and vaults, which are in two tables. Each row therefore has two columns, `elementId` and `vaultId`, and a CHECK constraint makes sure exactly one of them is set (constraint 11). Both columns are foreign keys, so a row can never point to an element or vault that does not exist. Prisma cannot express CHECK constraints, so they are added in SQL in the migration.
+
+Rows in `gymnast_elements` have their own generated number as id, because `elementId` and `vaultId` can be `null` and can therefore not be part of a primary key. The routine rows use the routine and the position as their key.
+
+### Ids
+
+The catalog keeps the readable ids from the data, such as `UB`, `UB-2`, `UB-2.305` and `UB-2.305-a`. They are stable, and they are the same ids that the API and the image file names use.
+
+The user data (coaches, gymnasts and routines) gets ids generated by Prisma (`cuid`). They say nothing about the data and cannot be guessed.
+
+### Removed elements
+
+Elements and vaults have a `removedAt` timestamp, which is `null` while the element is in the Code of Points. If a new Code of Points drops an element, it is marked as removed and not deleted, so routines and gymnast lists that use it still work. The seed script leaves removed items out of the search tables, so they are not found in the catalog search.
+
+How a removed item behaves in the API:
+
+- **By id:** `element(id)`, `vault(id)` and `catalogItem(id)` still return it, with `removed: true`, so links and saved data keep working.
+- **Variants:** `variants` leaves out removed variants.
+- **In a routine or a gymnast's list:** it stays, with `removed: true`, and the interface shows a warning next to it. It does not count in the D-value of the routine, so the coach sees that the routine is worth less until the element is replaced.
+- **Adding it again:** a removed item cannot be added to a gymnast's list. The catalog search never offers it, so the interface cannot add it to a routine either.
+
+### Timestamps
+
+`Coach.createdAt` is when the coach first logged in. A routine has `createdAt` and `updatedAt`, so the app can show when a routine was last changed. Both get the current time when the row is created, also when it is inserted outside Prisma. Prisma sets `updatedAt` again every time the routine row is updated. The items of a routine are separate rows, so changing them does not touch the routine row: `setRoutineItems` must therefore also update `updatedAt`, in the same transaction as the items.
+
+### Sort order of the apparatus
+
+`Apparatus.position` gives the order the apparatus are shown in: vault, uneven bars, balance beam, floor exercise. This is the order of the Code of Points, and not the alphabetical order of the ids.
+
+### Numbers
+
+`value` and `dValue` are stored as decimal numbers (`Decimal(3, 1)`), not as floating-point numbers. A sum of D-values is then exact: 0.1 + 0.2 is 0.3, and not 0.30000000000000004.
+
+Prisma returns these columns as `Decimal` objects, not as JavaScript numbers. The backend must add them up as `Decimal` and convert the result to a number only at the end, when it is sent in the API. Converting each value to a number first brings the rounding errors back.
+
+### Checks in the database
+
+Besides the constraints above, the database checks the ranges of the catalog values and the positions in a routine, so that wrong data cannot be inserted outside the app either:
+
+- `elements.value` is between 0.1 and 1.0.
+- `vaults.d_value` is greater than 0.
+- `routine_elements.position` is 1 or more. The backend writes the positions as 1, 2, 3 … without gaps when it replaces the items of a routine.
+
+The extraction script and the seed script check the values too. These CHECK constraints are written in SQL in the migrations, because Prisma cannot express them.
+
+### Limits
+
+The limits on user data (200 gymnasts per coach, 50 routines per gymnast and 50 items in a routine) are checked only in the backend, before it writes. Two requests from the same coach at exactly the same time can therefore go slightly over a limit. We accept this, because the limits are there to stop runaway use and not to enforce an exact number.
+
+### What happens on delete
+
+- **User data cascades.** Deleting a coach deletes their gymnasts. Deleting a gymnast deletes her routines and her element list. Deleting a routine deletes its items.
+- **Catalog rows that are in use cannot be deleted.** The database refuses to delete an element, vault, group or apparatus that something points to. Elements that leave the Code of Points get `removedAt` instead.
## Design decisions
@@ -185,15 +329,17 @@ Vaults are stored separately from elements because they work differently in the
If vaults were stored as elements, they would have a difficulty letter that is always empty. With vault as its own entity, the catalog describes each vault by its D-value, as the Code of Points does.
-In a routine, a vault is treated the same way as an element: it is an id in the routine's `elementIds`. A routine on vault holds exactly one vault. This keeps one rule for the D-value on every apparatus (the sum of the eight highest values), and with a single vault in the list, that sum is simply the vault's `dValue`. Vault ids always start with `VT-`, so the backend can tell a vault from an element by its id.
+In gymnastics a vault is an element like any other. It has its own table only because its difficulty is stored differently: one table would need a column whose meaning depends on the apparatus (a value from 0.1 to 1.0 for most rows, a D-value such as 2.6 for vaults), and the letter would be empty for every vault.
+
+In a routine, a vault is treated the same way as an element: it is one item in the routine, stored in `routine_elements` with `vaultId` set instead of `elementId`. A routine on vault holds exactly one vault. This keeps one rule for the D-value on every apparatus (the sum of the eight highest values), and with a single vault in the routine, that sum is simply the vault's `dValue`. Vault ids always start with `VT-`, so the API can also tell a vault from an element by its id.
### Variants are linked through a variant group
-Some cells in the Code of Points show two or three ways of performing the same element, each with its own drawing and description, but with the same code and the same value. We store each variant as its own element (`UB-1.101-a`, `UB-1.101-b`) so that each one has its own image and description and can be searched and added to a routine on its own.
+Some cells in the Code of Points show two or three ways of performing the same element, each with its own drawing and description, but with the same code and the same value. We store each variant as its own element (`UB-1.101-a`, `UB-1.101-b`) so that each one has its own image and description and can be added to a routine on its own. The catalog list shows one row per code, and the search finds the code by a word from any of its variants. The user then chooses the variant on the detail page, where all the variants are shown.
-The variants are linked through a variant group that holds a list of their ids. Adding or removing a variant only changes the variant group, not the other elements. Because variants share the same code, the D-value counts each code only once (constraint 12).
+The variants are linked through a variant group: each variant has a `variantGroupId` that points to the group. Adding or removing a variant only changes that variant, not the others. Because variants share the same code, the D-value counts each code only once (constraint 13).
-We use one list (`variantIds`) instead of separate lists for elements and vaults, because a variant group never contains both. The type is given by the id: vault ids always start with `VT-`.
+A variant group never contains both elements and vaults. The type is given by the id: vault ids and vault group ids always start with `VT-`.
### Every id has a variant letter
@@ -217,7 +363,7 @@ We store `googleId` and not the token itself. The token expires after a short ti
### A gymnast has a list of elements
-Besides routines, a coach wants an overview of which elements each gymnast can do or is working on. This is stored as a list of element and vault ids on the gymnast. We do not distinguish between elements the gymnast has mastered and elements she is practicing, since the list is meant as a planning overview for the coach.
+Besides routines, a coach wants an overview of which elements each gymnast can do or is working on. This is stored as rows in `gymnast_elements`, one per element or vault. We do not distinguish between elements the gymnast has mastered and elements she is practicing, since the list is meant as a planning overview for the coach.
The list is independent of the routines: a gymnast can practice an element that is not in any routine yet, and a routine can contain an element the gymnast has not mastered. The list can later be used to suggest elements when the coach builds a routine.
@@ -225,9 +371,9 @@ The list is independent of the routines: a gymnast can practice an element that
A coach often tries out different versions of a routine, for example with or without a new element, to see how much each version is worth. A gymnast can therefore have any number of routines on the same apparatus, each with its own name. Because the D-value is calculated for each routine, the versions can be compared side by side.
-### Elements in a routine are a list of ids
+### Elements in a routine are rows with a position
-A routine stores its elements as an ordered list of element ids. The order in the list is the order in the routine, so no separate position field is needed. If we later need more information per element in a routine (for example connections between elements or coach notes), the list can be changed to a list of objects without affecting the rest of the model.
+A routine stores its items as rows in `routine_elements`, each with a `position`. The order of the positions is the order in the routine. When the coach changes a routine, the API replaces all its rows in one go (`setRoutineItems`), so the backend never has to move single rows around. If we later need more information per item in a routine (for example connections between elements or coach notes), it can be added as columns on these rows without affecting the rest of the model.
### The D-value is calculated, not stored
@@ -239,7 +385,7 @@ Visitors who are not logged in should also be able to try out routines, but they
### The difficulty letter is calculated
-The difficulty letter (A–J) and the value (0.1–1.0) always match one to one, so we only store `value`. The letter is calculated when it is shown, and filtering by difficulty is done on `value`.
+The difficulty letter (A–J) and the value (0.1–1.0) always match one to one, so we only store `value`, and all the logic uses the value. The letter is only shown in the frontend, which calculates it from the value (see [Difficulty letters are only handled in the frontend](API.md#difficulty-letters-are-only-handled-in-the-frontend) in API.md). The catalog filter takes a list of values (several can be chosen): when the user chooses A and B in the interface, the frontend sends `[0.1, 0.2]`. Sorting by difficulty is only offered within one apparatus, because comparing element values with vault D-values gives no useful order (see [API.md](API.md#why-difficulty-is-only-sorted-within-an-apparatus)).
### The image is stored as a file name
@@ -247,4 +393,41 @@ The `image` field holds only the file name, for example `UB-1.101-a.webp`, and n
### The search uses two derived tables
-The catalog is searched through two extra tables that are not part of the diagram above, because they hold no new information: `CatalogEntry` (one row for each element and vault, with the shared columns and the sort keys) and `CatalogWord` (the lowercase words of each entry, for the word search). The seed script fills both from the catalog, and they are read-only. They exist because a database query cannot combine the element and vault tables into one sorted list with a cursor, and because the backend does not use raw SQL. The fields and the reasoning are in [API.md](API.md#search-without-raw-sql).
+The catalog is searched through two extra tables that are not part of the diagram above, because they hold no new information: `CatalogEntry` (one row per code, with the shared columns and the sort keys) and `CatalogWord` (the lowercase words of all variants of each code, for the word search). The seed script fills both from the catalog, and they are read-only. They exist because a database query cannot combine the element and vault tables into one sorted list with a cursor, and because the backend does not use raw SQL. The fields and the reasoning are in [API.md](API.md#search-without-raw-sql).
+
+The catalog list shows one row per code and not one per variant, so `CatalogEntry` only has one variant of each code: the first one that is not removed, normally `-a`. `CatalogWord` holds the words of all the variants, so the search still finds a code by a word that is only in one of its other variants.
+
+### Difficulty is sorted within an apparatus and filtered by value
+
+Sorting by difficulty is only allowed when an apparatus is selected, because comparing difficulty across apparatus gives the user nothing of value: element values (0.1–1.0) and vault D-values (about 2.0–6.4) are different scales. The difficulty filter is a list of chosen values, not a range, and only the frontend shows them as letters. The reasoning is in [API.md](API.md#why-difficulty-is-only-sorted-within-an-apparatus).
+
+### The gymnast's list is changed one item at a time
+
+The gymnast's list of elements is part of the API (`Gymnast.elements`), with one mutation to add an item and one to remove it. A coach adds elements to the list one by one from the search, so this matches how the list is used. A routine is different: its order matters and the coach rearranges it, so `setRoutineItems` replaces the whole routine at once.
+
+Adding an item that is already in the list, or removing one that is not, does nothing instead of giving an error. Clicking twice, or a slow network that sends the same request twice, then gives the same result as clicking once.
+
+### Removed elements stay, but do not count
+
+When an element is no longer in the Code of Points, it is marked with `removedAt` and kept, instead of deleted. We decided this because coaches have saved routines and lists that use it, and deleting it would break them or change them without warning.
+
+A removed element:
+
+- stays in routines and gymnast lists, marked as removed, and the interface shows a warning next to it
+- does not count in the D-value, so the coach can see that the routine is worth less until the element is replaced
+- can still be fetched by id, so saved links work
+- is not found in the search and cannot be added to a gymnast's list
+
+We considered letting removed elements still count in the D-value of saved routines. We chose not to, because the D-value would then show a value the routine no longer has under the current Code of Points.
+
+The catalog list uses the first variant of a code that is not removed. If only the `-a` variant is removed, the code is still found through its `-b` variant.
+
+### The same element can appear twice in a routine
+
+A routine may contain the same element more than once, at different positions. It still counts only once in the D-value, as in the Code of Points. We allow it because connection bonuses, which we plan to add later, can involve the same element twice (for example C + C). Forbidding duplicates now would have to be undone then.
+
+### Value ranges are checked in the database, limits only in the backend
+
+The database refuses an element value outside 0.1–1.0, a vault D-value of 0 or less, and a routine position below 1. These checks are cheap, and they stop wrong data even if it is inserted outside the app, for example by a script.
+
+The limits on user data (200 gymnasts, 50 routines per gymnast, 50 items per routine) are only checked in the backend. Two requests at exactly the same time can go slightly over a limit. We accept this, because the limits are there to stop runaway use and not to guarantee an exact number, and enforcing them in the database would need locking or triggers.
diff --git a/docs/UI_PLANS.md b/docs/UI_PLANS.md
index 05691a3..6035dc2 100644
--- a/docs/UI_PLANS.md
+++ b/docs/UI_PLANS.md
@@ -14,7 +14,7 @@ Anyone who uses the website without logging in. This can be gymnasts, coaches, j
- As a visitor, I want to search for an element by name or description, so that I can quickly find its value.
- As a visitor, I want to filter elements by apparatus and group, so that I only see the elements that are relevant to me.
-- As a visitor, I want to see only the elements of one value, so that I can find a new element to practice.
+- As a visitor, I want to see only the elements of one or more difficulty letters (for example D and E), so that I can find a new element to practice.
- As a visitor, I want to look up an element's code and value, so that I can check it during or after a competition.
- As a visitor, I want to try out a routine in the calculator, so that I can see what it would be worth.
diff --git a/docs/examples/README.md b/docs/examples/README.md
index 048725f..26e164b 100644
--- a/docs/examples/README.md
+++ b/docs/examples/README.md
@@ -6,15 +6,21 @@ These JSON files show how the data described in [DATA_MODEL.md](../DATA_MODEL.md
**The data is generated by AI.** All the JSON files were generated by Claude Code from a task description written by the team. See [AI_USAGE.md](../AI_USAGE.md).
-The ids of coaches, gymnasts and routines (`coach-1`, `gymnast-1`, `routine-1`) are placeholders for the internal ids the database will generate.
+The ids of coaches, gymnasts and routines (`coach-1`, `gymnast-1`, `routine-1`) are placeholders for the ids Prisma generates (cuid). The rows in `gymnastElements.json` are numbered by the database.
-| File | Entity | Contents |
-| -------------------- | ------------ | --------------------------------------------------------------------- |
-| `apparatus.json` | Apparatus | All 4 apparatus |
-| `elementGroups.json` | ElementGroup | All 22 element groups |
-| `elements.json` | Element | 9 elements on uneven bars, beam and floor, including two variants |
-| `vaults.json` | Vault | 2 vaults from different groups |
-| `variantGroups.json` | VariantGroup | 1 variant group, linking the two variants of `UB-1.101` |
-| `coaches.json` | Coach | 1 coach |
-| `gymnasts.json` | Gymnast | 2 gymnasts with their lists of elements |
-| `routines.json` | Routine | 5 routines, including two on the same apparatus and one vault routine |
+The files follow the tables in [DATA_MODEL.md](../DATA_MODEL.md#storage-in-postgresql): lists are stored as rows, and elements and vaults point to their variant group.
+
+| File | Entity | Contents |
+| ---------------------- | -------------- | ----------------------------------------------------------------------------- |
+| `apparatus.json` | Apparatus | All 4 apparatus, with their sort order |
+| `elementGroups.json` | ElementGroup | All 22 element groups |
+| `elements.json` | Element | 9 elements on uneven bars, beam and floor, including two variants of one code |
+| `vaults.json` | Vault | 2 vaults from different groups |
+| `variantGroups.json` | VariantGroup | 1 variant group. The two variants of `UB-1.101` point to it |
+| `coaches.json` | Coach | 1 coach |
+| `gymnasts.json` | Gymnast | 2 gymnasts |
+| `gymnastElements.json` | GymnastElement | The two gymnasts' lists of elements and vaults, one row per item |
+| `routines.json` | Routine | 5 routines, including two on the same apparatus and one vault routine |
+| `routineElements.json` | RoutineElement | The items of the routines, one row per item with its position |
+
+The search tables `CatalogEntry` and `CatalogWord` are not included, because the seed script derives them from the catalog.
diff --git a/docs/examples/apparatus.json b/docs/examples/apparatus.json
index 391287d..d16e6e0 100644
--- a/docs/examples/apparatus.json
+++ b/docs/examples/apparatus.json
@@ -1,6 +1,22 @@
[
- { "id": "VT", "name": "Vault" },
- { "id": "UB", "name": "Uneven bars" },
- { "id": "BB", "name": "Balance beam" },
- { "id": "FX", "name": "Floor exercise" }
+ {
+ "id": "VT",
+ "name": "Vault",
+ "position": 1
+ },
+ {
+ "id": "UB",
+ "name": "Uneven bars",
+ "position": 2
+ },
+ {
+ "id": "BB",
+ "name": "Balance beam",
+ "position": 3
+ },
+ {
+ "id": "FX",
+ "name": "Floor exercise",
+ "position": 4
+ }
]
diff --git a/docs/examples/elementGroups.json b/docs/examples/elementGroups.json
index be0d045..84003c4 100644
--- a/docs/examples/elementGroups.json
+++ b/docs/examples/elementGroups.json
@@ -3,51 +3,86 @@
"id": "VT-1",
"apparatusId": "VT",
"number": 1,
- "name": "Vaults without salto (handspring, Yamashita, round-off) with or without LA turn in 1st and/or 2nd flight phase"
+ "name": "Handspring, Yamashita, round-off with or without turn in 1st and/or 2nd flight phase"
},
{
"id": "VT-2",
"apparatusId": "VT",
"number": 2,
- "name": "Handspring fwd with or without 1/1 turn (360°) in 1st flight phase – salto fwd or bwd with or without LA turn in 2nd flight phase"
+ "name": "Handspring fwd with/without 1/1 turn (360°) in 1st flight phase – salto fwd/bwd with/without twist in 2nd flight phase"
},
{
"id": "VT-3",
"apparatusId": "VT",
"number": 3,
- "name": "Handspring with ¼–½ turn (90°–180°) in 1st flight phase (Tsukahara) – salto bwd with or without LA turn in 2nd flight phase"
+ "name": "Handspring with ¼ - ½ turn (90°-180°) in 1st flight phase (Tsukahara) – salto bwd with/without twist in 2nd flight phase"
},
{
"id": "VT-4",
"apparatusId": "VT",
"number": 4,
- "name": "Round-off (Yurchenko) with or without ¾ turn (270°) in 1st flight phase – salto bwd with or without LA turn in 2nd flight phase"
+ "name": "Round-off (Yurchenko) with/wo ¾ turn (270°) in 1st flight phase – salto bwd with/without twist in 2nd flight phase"
},
{
"id": "VT-5",
"apparatusId": "VT",
"number": 5,
- "name": "Round-off with ½ turn (180°) in 1st flight phase – salto fwd or bwd with or without LA turn in 2nd flight phase"
+ "name": "Round-off with ½ turn (180°) in 1st flight phase – salto fwd/bwd with/without twist in 2nd flight phase"
+ },
+ {
+ "id": "UB-1",
+ "apparatusId": "UB",
+ "number": 1,
+ "name": "Mounts"
},
- { "id": "UB-1", "apparatusId": "UB", "number": 1, "name": "Mounts" },
{
"id": "UB-2",
"apparatusId": "UB",
"number": 2,
"name": "Casts and clear hip circles"
},
- { "id": "UB-3", "apparatusId": "UB", "number": 3, "name": "Giant circles" },
- { "id": "UB-4", "apparatusId": "UB", "number": 4, "name": "Stalder circles" },
- { "id": "UB-5", "apparatusId": "UB", "number": 5, "name": "Pike circles" },
- { "id": "UB-6", "apparatusId": "UB", "number": 6, "name": "Dismounts" },
- { "id": "BB-1", "apparatusId": "BB", "number": 1, "name": "Mounts" },
+ {
+ "id": "UB-3",
+ "apparatusId": "UB",
+ "number": 3,
+ "name": "Giant circles"
+ },
+ {
+ "id": "UB-4",
+ "apparatusId": "UB",
+ "number": 4,
+ "name": "Stalder circles"
+ },
+ {
+ "id": "UB-5",
+ "apparatusId": "UB",
+ "number": 5,
+ "name": "Pike circles"
+ },
+ {
+ "id": "UB-6",
+ "apparatusId": "UB",
+ "number": 6,
+ "name": "Dismounts"
+ },
+ {
+ "id": "BB-1",
+ "apparatusId": "BB",
+ "number": 1,
+ "name": "Mounts"
+ },
{
"id": "BB-2",
"apparatusId": "BB",
"number": 2,
"name": "Gymnastic leaps, jumps and hops"
},
- { "id": "BB-3", "apparatusId": "BB", "number": 3, "name": "Gymnastic turns" },
+ {
+ "id": "BB-3",
+ "apparatusId": "BB",
+ "number": 3,
+ "name": "Gymnastic turns"
+ },
{
"id": "BB-4",
"apparatusId": "BB",
@@ -60,14 +95,24 @@
"number": 5,
"name": "Acrobatic flight"
},
- { "id": "BB-6", "apparatusId": "BB", "number": 6, "name": "Dismounts" },
+ {
+ "id": "BB-6",
+ "apparatusId": "BB",
+ "number": 6,
+ "name": "Dismounts"
+ },
{
"id": "FX-1",
"apparatusId": "FX",
"number": 1,
"name": "Gymnastic leaps, jumps and hops"
},
- { "id": "FX-2", "apparatusId": "FX", "number": 2, "name": "Gymnastic turns" },
+ {
+ "id": "FX-2",
+ "apparatusId": "FX",
+ "number": 2,
+ "name": "Gymnastic turns"
+ },
{
"id": "FX-3",
"apparatusId": "FX",
@@ -78,7 +123,12 @@
"id": "FX-4",
"apparatusId": "FX",
"number": 4,
- "name": "Saltos forward and sideward"
+ "name": "Saltos forward & sideward"
},
- { "id": "FX-5", "apparatusId": "FX", "number": 5, "name": "Saltos backward" }
+ {
+ "id": "FX-5",
+ "apparatusId": "FX",
+ "number": 5,
+ "name": "Saltos backward"
+ }
]
diff --git a/docs/examples/elements.json b/docs/examples/elements.json
index 71a36c9..3dd3c13 100644
--- a/docs/examples/elements.json
+++ b/docs/examples/elements.json
@@ -2,82 +2,100 @@
{
"id": "UB-1.101-a",
"elementGroupId": "UB-1",
+ "variantGroupId": "UB-1.101",
"code": "1.101",
"value": 0.1,
"description": "Jump with straight body to hang on HB – swing fwd with ½ turn (180°)",
"name": null,
- "image": "UB-1.101-a.webp"
+ "image": "UB-1.101-a.webp",
+ "removedAt": null
},
{
"id": "UB-1.101-b",
"elementGroupId": "UB-1",
+ "variantGroupId": "UB-1.101",
"code": "1.101",
"value": 0.1,
"description": "Jump with legs straddled to hang on HB – swing fwd with ½ turn (180°)",
"name": null,
- "image": "UB-1.101-b.webp"
+ "image": "UB-1.101-b.webp",
+ "removedAt": null
},
{
"id": "UB-3.304-a",
"elementGroupId": "UB-3",
+ "variantGroupId": null,
"code": "3.304",
"value": 0.3,
"description": "Giant circle bwd in mixed grip with ½ turn (180°) through handstand",
"name": null,
- "image": "UB-3.304-a.webp"
+ "image": "UB-3.304-a.webp",
+ "removedAt": null
},
{
"id": "BB-2.201-a",
"elementGroupId": "BB-2",
+ "variantGroupId": null,
"code": "2.201",
"value": 0.2,
"description": "Leap fwd with ½ turn (180°), landing on one leg in cross position",
"name": null,
- "image": "BB-2.201-a.webp"
+ "image": "BB-2.201-a.webp",
+ "removedAt": null
},
{
"id": "BB-5.403-a",
"elementGroupId": "BB-5",
+ "variantGroupId": null,
"code": "5.403",
"value": 0.4,
"description": "Salto swd tucked from a two-step approach, landing in side position",
"name": "Lindqvist",
- "image": "BB-5.403-a.webp"
+ "image": "BB-5.403-a.webp",
+ "removedAt": null
},
{
"id": "BB-6.305-a",
"elementGroupId": "BB-6",
+ "variantGroupId": null,
"code": "6.305",
"value": 0.3,
"description": "Round-off at end of beam – salto bwd piked with ½ turn (180°)",
"name": null,
- "image": "BB-6.305-a.webp"
+ "image": "BB-6.305-a.webp",
+ "removedAt": null
},
{
"id": "FX-1.203-a",
"elementGroupId": "FX-1",
+ "variantGroupId": null,
"code": "1.203",
"value": 0.2,
"description": "Hop with free leg at horizontal and 1/1 turn (360°)",
"name": null,
- "image": "FX-1.203-a.webp"
+ "image": "FX-1.203-a.webp",
+ "removedAt": null
},
{
"id": "FX-2.302-a",
"elementGroupId": "FX-2",
+ "variantGroupId": null,
"code": "2.302",
"value": 0.3,
"description": "2/1 turn (720°) on one leg with free leg held bent in front",
"name": null,
- "image": "FX-2.302-a.webp"
+ "image": "FX-2.302-a.webp",
+ "removedAt": null
},
{
"id": "FX-5.506-a",
"elementGroupId": "FX-5",
+ "variantGroupId": null,
"code": "5.506",
"value": 0.5,
"description": "Double salto bwd with first salto tucked and second salto piked, with 1/1 turn (360°)",
"name": "Aasen",
- "image": "FX-5.506-a.webp"
+ "image": "FX-5.506-a.webp",
+ "removedAt": null
}
]
diff --git a/docs/examples/gymnastElements.json b/docs/examples/gymnastElements.json
new file mode 100644
index 0000000..65c2d24
--- /dev/null
+++ b/docs/examples/gymnastElements.json
@@ -0,0 +1,62 @@
+[
+ {
+ "id": 1,
+ "gymnastId": "gymnast-1",
+ "elementId": "UB-1.101-a",
+ "vaultId": null
+ },
+ {
+ "id": 2,
+ "gymnastId": "gymnast-1",
+ "elementId": "UB-1.101-b",
+ "vaultId": null
+ },
+ {
+ "id": 3,
+ "gymnastId": "gymnast-1",
+ "elementId": "UB-3.304-a",
+ "vaultId": null
+ },
+ {
+ "id": 4,
+ "gymnastId": "gymnast-1",
+ "elementId": "BB-2.201-a",
+ "vaultId": null
+ },
+ {
+ "id": 5,
+ "gymnastId": "gymnast-1",
+ "elementId": null,
+ "vaultId": "VT-1.12-a"
+ },
+ {
+ "id": 6,
+ "gymnastId": "gymnast-1",
+ "elementId": null,
+ "vaultId": "VT-4.23-a"
+ },
+ {
+ "id": 7,
+ "gymnastId": "gymnast-2",
+ "elementId": "BB-2.201-a",
+ "vaultId": null
+ },
+ {
+ "id": 8,
+ "gymnastId": "gymnast-2",
+ "elementId": "BB-5.403-a",
+ "vaultId": null
+ },
+ {
+ "id": 9,
+ "gymnastId": "gymnast-2",
+ "elementId": "FX-1.203-a",
+ "vaultId": null
+ },
+ {
+ "id": 10,
+ "gymnastId": "gymnast-2",
+ "elementId": "FX-2.302-a",
+ "vaultId": null
+ }
+]
diff --git a/docs/examples/gymnasts.json b/docs/examples/gymnasts.json
index 81c2cd4..3aa0acf 100644
--- a/docs/examples/gymnasts.json
+++ b/docs/examples/gymnasts.json
@@ -2,20 +2,11 @@
{
"id": "gymnast-1",
"coachId": "coach-1",
- "name": "Ingrid Haugen",
- "elementIds": [
- "UB-1.101-a",
- "UB-1.101-b",
- "UB-3.304-a",
- "BB-2.201-a",
- "VT-1.12-a",
- "VT-4.23-a"
- ]
+ "name": "Ingrid Haugen"
},
{
"id": "gymnast-2",
"coachId": "coach-1",
- "name": "Emma Berg",
- "elementIds": ["BB-2.201-a", "BB-5.403-a", "FX-1.203-a", "FX-2.302-a"]
+ "name": "Emma Berg"
}
]
diff --git a/docs/examples/routineElements.json b/docs/examples/routineElements.json
new file mode 100644
index 0000000..0657a05
--- /dev/null
+++ b/docs/examples/routineElements.json
@@ -0,0 +1,68 @@
+[
+ {
+ "routineId": "routine-1",
+ "position": 1,
+ "elementId": "UB-1.101-a",
+ "vaultId": null
+ },
+ {
+ "routineId": "routine-1",
+ "position": 2,
+ "elementId": "UB-3.304-a",
+ "vaultId": null
+ },
+ {
+ "routineId": "routine-2",
+ "position": 1,
+ "elementId": "UB-1.101-b",
+ "vaultId": null
+ },
+ {
+ "routineId": "routine-2",
+ "position": 2,
+ "elementId": "UB-3.304-a",
+ "vaultId": null
+ },
+ {
+ "routineId": "routine-3",
+ "position": 1,
+ "elementId": null,
+ "vaultId": "VT-1.12-a"
+ },
+ {
+ "routineId": "routine-4",
+ "position": 1,
+ "elementId": "BB-2.201-a",
+ "vaultId": null
+ },
+ {
+ "routineId": "routine-4",
+ "position": 2,
+ "elementId": "BB-5.403-a",
+ "vaultId": null
+ },
+ {
+ "routineId": "routine-4",
+ "position": 3,
+ "elementId": "BB-6.305-a",
+ "vaultId": null
+ },
+ {
+ "routineId": "routine-5",
+ "position": 1,
+ "elementId": "FX-1.203-a",
+ "vaultId": null
+ },
+ {
+ "routineId": "routine-5",
+ "position": 2,
+ "elementId": "FX-2.302-a",
+ "vaultId": null
+ },
+ {
+ "routineId": "routine-5",
+ "position": 3,
+ "elementId": "FX-5.506-a",
+ "vaultId": null
+ }
+]
diff --git a/docs/examples/routines.json b/docs/examples/routines.json
index 4531d46..e3bd223 100644
--- a/docs/examples/routines.json
+++ b/docs/examples/routines.json
@@ -4,34 +4,39 @@
"gymnastId": "gymnast-1",
"name": "Competition routine",
"apparatusId": "UB",
- "elementIds": ["UB-1.101-a", "UB-3.304-a"]
+ "createdAt": "2026-10-01T12:00:00Z",
+ "updatedAt": "2026-10-01T18:30:00Z"
},
{
"id": "routine-2",
"gymnastId": "gymnast-1",
"name": "With straddled mount",
"apparatusId": "UB",
- "elementIds": ["UB-1.101-b", "UB-3.304-a"]
+ "createdAt": "2026-10-02T12:00:00Z",
+ "updatedAt": "2026-10-02T18:30:00Z"
},
{
"id": "routine-3",
"gymnastId": "gymnast-1",
"name": "Competition vault",
"apparatusId": "VT",
- "elementIds": ["VT-1.12-a"]
+ "createdAt": "2026-10-03T12:00:00Z",
+ "updatedAt": "2026-10-03T18:30:00Z"
},
{
"id": "routine-4",
"gymnastId": "gymnast-2",
"name": "Competition routine",
"apparatusId": "BB",
- "elementIds": ["BB-2.201-a", "BB-5.403-a", "BB-6.305-a"]
+ "createdAt": "2026-10-04T12:00:00Z",
+ "updatedAt": "2026-10-04T18:30:00Z"
},
{
"id": "routine-5",
"gymnastId": "gymnast-2",
"name": "Competition routine",
"apparatusId": "FX",
- "elementIds": ["FX-1.203-a", "FX-2.302-a", "FX-5.506-a"]
+ "createdAt": "2026-10-05T12:00:00Z",
+ "updatedAt": "2026-10-05T18:30:00Z"
}
]
diff --git a/docs/examples/variantGroups.json b/docs/examples/variantGroups.json
index 1f5e194..8c5ee8d 100644
--- a/docs/examples/variantGroups.json
+++ b/docs/examples/variantGroups.json
@@ -1,6 +1,5 @@
[
{
- "id": "UB-1.101",
- "variantIds": ["UB-1.101-a", "UB-1.101-b"]
+ "id": "UB-1.101"
}
]
diff --git a/docs/examples/vaults.json b/docs/examples/vaults.json
index 65108de..27fb107 100644
--- a/docs/examples/vaults.json
+++ b/docs/examples/vaults.json
@@ -2,19 +2,23 @@
{
"id": "VT-1.12-a",
"elementGroupId": "VT-1",
+ "variantGroupId": null,
"code": "1.12",
"dValue": 2.4,
"description": "Handspring fwd on – ¾ turn (270°) off",
"name": null,
- "image": "VT-1.12-a.webp"
+ "image": "VT-1.12-a.webp",
+ "removedAt": null
},
{
"id": "VT-4.23-a",
"elementGroupId": "VT-4",
+ "variantGroupId": null,
"code": "4.23",
"dValue": 4.4,
"description": "Round-off, flic-flac on – salto bwd piked with 1¼ turn (450°) off",
"name": "Dahl",
- "image": "VT-4.23-a.webp"
+ "image": "VT-4.23-a.webp",
+ "removedAt": null
}
]
diff --git a/package-lock.json b/package-lock.json
index e24f242..2d4a40e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -42,6 +42,7 @@
"backend": {
"version": "0.0.0",
"dependencies": {
+ "@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0"
},
"devDependencies": {
@@ -1069,6 +1070,18 @@
"url": "https://github.com/sponsors/oxc-project"
}
},
+ "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==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/driver-adapter-utils": "7.10.0",
+ "@types/pg": "^8.16.0",
+ "pg": "^8.16.3",
+ "postgres-array": "3.0.4"
+ }
+ },
"node_modules/@prisma/client": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz",
@@ -1116,7 +1129,6 @@
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.0.tgz",
"integrity": "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==",
- "devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/dev": {
@@ -1150,6 +1162,15 @@
"devOptional": true,
"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==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "7.10.0"
+ }
+ },
"node_modules/@prisma/engines": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.0.tgz",
@@ -1953,12 +1974,22 @@
"version": "24.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
+ "node_modules/@types/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
+ }
+ },
"node_modules/@types/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
@@ -4315,6 +4346,104 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.1",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.1",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.1"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
+ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pg-types/node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4397,6 +4526,45 @@
"url": "https://github.com/sponsors/porsager"
}
},
+ "node_modules/postgres-array": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
+ "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -4748,6 +4916,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
@@ -4889,7 +5066,6 @@
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
- "dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
@@ -5148,6 +5324,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",