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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ We are building a web application that helps gymnastics coaches plan their gymna

## Status

The first delivery was a specification. There is no working application yet, but the catalog data has been extracted from the Code of Points and is in [`data/`](data/).
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.

Expand Down Expand Up @@ -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

Expand All @@ -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`.

Expand Down Expand Up @@ -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` |
Expand All @@ -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:
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"typescript": "~6.0.2"
},
"dependencies": {
"@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0"
}
}
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 3 additions & 0 deletions backend/prisma/migrations/migration_lock.toml
Original file line number Diff line number Diff line change
@@ -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"
Loading