diff --git a/.claude/DONE b/.claude/DONE
deleted file mode 100644
index c1f56f9..0000000
--- a/.claude/DONE
+++ /dev/null
@@ -1 +0,0 @@
-MVP ferdig og verifisert 2026-09-30. Se docs/status.md.
diff --git a/.env.example b/.env.example
index 88bb12d..2e4ddae 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,8 @@
# Kopier til .env og tilpass. .env skal aldri committes.
+# `npm run setup` gjør kopieringen for deg (og overskriver aldri en eksisterende .env).
+#
+# Bruker eller passord annerledes enn under? Sett dem i DATABASE_URL og TEST_DATABASE_URL, f.eks.
+# postgres://it2810:hemmelig@localhost:5432/project2
# Backend
PORT=3001
@@ -16,13 +20,20 @@ IMDB_MIN_VOTES=100
# Frontend (valgfri – standard er /graphql)
VITE_GRAPHQL_URL=
-# Bilder og handling fra TMDB (https://www.themoviedb.org/settings/api). Uten nøkkel vises
-# plassholdere i stedet for plakater.
+# Bilder og handling fra TMDB (https://www.themoviedb.org/settings/api).
+# La stå tom i utvikling: `npm run dev` starter da en falsk TMDB-server (port 3999) og en falsk
+# Internet Archive (port 3998) og setter URL-ene under selv, så plakater og gratisfilmer vises uten
+# nøkler. Med ekte nøkkel brukes ekte TMDB. (Backend alene, uten `npm run dev`, viser plassholdere.)
TMDB_API_KEY=
-# Overstyres i utvikling/E2E for å bruke den lokale falske TMDB-serveren (npm run tmdb:mock -w backend):
+# Settes automatisk av `npm run dev` når nøkkelen er tom. Manuelt (npm run tmdb:mock -w backend):
# TMDB_API_URL=http://localhost:3999/3
# TMDB_IMAGE_URL=http://localhost:3999/t/p
-# Lovlige gratisfilmer fra Internet Archive (npm run db:archive). Standard er den ekte tjenesten;
-# pek mot den falske serveren (npm run archive:mock -w backend) i utvikling/E2E:
+# Lovlige gratisfilmer fra Internet Archive (npm run db:archive). Standard er den ekte tjenesten.
+# `npm run dev` bruker den falske serveren (port 3998) når TMDB_API_KEY er tom og denne er utkommentert.
+# Manuelt (npm run archive:mock -w backend):
# ARCHIVE_URL=http://localhost:3998
+
+# Antall syntetiske titler som `npm run setup` genererer hvis data/ er tom (standard 20000).
+# 120000 gir det store settet, men tar lengre tid.
+# FIXTURE_SIZE=20000
diff --git a/.gitignore b/.gitignore
index f4a5630..a58a0bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,5 +14,6 @@ blob-report/
.vite/
*.tsbuildinfo
.DS_Store
+.claude/DONE
.claude/DONE.local
data/e2e/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 3d15688..df22dda 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -13,6 +13,7 @@ default:
stages:
- check
- test
+ - e2e
variables:
HUSKY: '0'
@@ -39,3 +40,42 @@ unit-tests:
TEST_DATABASE_URL: postgres://postgres@postgres:5432/project2_test
script:
- npm test
+
+# Bygger begge pakkene slik at en ødelagt produksjonsbygging oppdages før deploy.
+build:
+ stage: test
+ script:
+ - npm run build
+ artifacts:
+ paths:
+ - frontend/dist/
+ expire_in: 1 week
+
+# E2E mot ekte backend og database. Playwright-bildet har nettleserne ferdig installert, så
+# `playwright install` trengs ikke. Versjonen må følge @playwright/test i package-lock.json.
+# Playwright starter selv falsk TMDB, falsk Internet Archive, backend og frontend (se
+# playwright.config.ts), og global-setup.ts migrerer og fyller databasen med syntetiske data.
+e2e:
+ stage: e2e
+ # Bygger selv via webServer i playwright.config.ts; dist fra build-jobben trengs ikke.
+ dependencies: []
+ image: mcr.microsoft.com/playwright:v1.63.0-noble
+ services:
+ - name: postgres:16
+ alias: postgres
+ variables:
+ POSTGRES_DB: project2_e2e
+ POSTGRES_USER: postgres
+ POSTGRES_HOST_AUTH_METHOD: trust
+ E2E_DATABASE_URL: postgres://postgres@postgres:5432/project2_e2e
+ script:
+ - npm run test:e2e
+ artifacts:
+ # always: en flaky test (feiler først, består på retry) gir også trace i test-results/.
+ when: always
+ reports:
+ junit: test-results/junit.xml
+ paths:
+ - playwright-report/
+ - test-results/
+ expire_in: 1 week
diff --git a/.gitlab/issue_templates/Oppgave.md b/.gitlab/issue_templates/Oppgave.md
new file mode 100644
index 0000000..3893c49
--- /dev/null
+++ b/.gitlab/issue_templates/Oppgave.md
@@ -0,0 +1,22 @@
+## Mål
+
+
+
+## Ferdig når
+
+
+
+- [ ]
+- [ ] Tester er lagt til eller oppdatert, og lint, typecheck, `npm test` og E2E er grønne
+- [ ] Merge request er godkjent av en annen i gruppa
+
+## Ansvarlig
+
+
+
+- Ansvarlig: @
+- Reviewer: @
+
+## Notater
+
+
diff --git a/.gitlab/merge_request_templates/Default.md b/.gitlab/merge_request_templates/Default.md
new file mode 100644
index 0000000..511e04d
--- /dev/null
+++ b/.gitlab/merge_request_templates/Default.md
@@ -0,0 +1,33 @@
+## Oppsummering
+
+
+
+## Issue
+
+Closes #
+
+
+
+## Slik er det testet
+
+
+
+-
+
+## Sjekkliste
+
+- [ ] `npm run lint` er grønn
+- [ ] `npm run typecheck` er grønn
+- [ ] `npm test` er grønn (backend mot PostgreSQL og komponenttester)
+- [ ] `npm run test:e2e` er grønn, eller endringen berører ikke brukerflyter
+- [ ] Tilgjengelighet: tastatur, synlig fokus og skjermlesertekst er sjekket for nye eller endrede elementer
+- [ ] Utypisk bruk er testet der det er relevant: tomt søk, ingen treff, spesialtegn, lange strenger, nettverksfeil
+- [ ] Dokumentasjon i `docs/` er oppdatert hvis oppførsel eller oppsett er endret
+- [ ] Ingen hemmeligheter (`.env`, nøkler) i diffen
+
+## Review
+
+Reviewer: @
+
+
diff --git a/.prettierignore b/.prettierignore
index f4e4841..8e933e6 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -5,3 +5,4 @@ playwright-report
test-results
package-lock.json
data
+.claude
diff --git a/CLAUDE.md b/CLAUDE.md
index 5919673..371f6dc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,16 +4,16 @@ Du jobber autonomt. Nicolay ser ikke på skjermen før prosjektet er ferdig. Ikk
## Roller
-- **Hovedsesjonen (Opus) er daglig leder.** Den planlegger, deler opp arbeid, delegerer til subagenter, verifiserer og krysser av i `PLAN.md`. Lederen skriver helst ikke produksjonskode selv.
+- **Hovedsesjonen (Opus) er daglig leder.** Den planlegger, deler opp arbeid, delegerer til subagenter, verifiserer og krysser av i `docs/prosess/plan.md`. Lederen skriver helst ikke produksjonskode selv.
- **Subagenter (Sonnet):** `backend-utvikler`, `frontend-utvikler`, `tester`, `reviewer`. Gi dem små, avgrensede oppgaver med tydelig ferdigkriterium. Kjør uavhengige oppgaver parallelt.
## Arbeidsløkke (hver runde)
-1. Les `PLAN.md` og `BLOCKERS.md`.
+1. Les `docs/prosess/plan.md` og `docs/prosess/blokkeringer.md`.
2. Velg neste uavkryssede punkt (eller flere uavhengige).
3. Deleger → la `reviewer` gå gjennom endringen → la `tester` kjøre relevante tester.
4. Kjør selv `npm run lint && npm run typecheck && npm test` i berørte pakker. Rødt = ikke ferdig.
-5. Commit, kryss av i `PLAN.md`, skriv én linje i `docs/ki-logg.md`.
+5. Commit, kryss av i `docs/prosess/plan.md`, skriv én linje i `docs/ki/ki-logg.md`.
6. Er alle MVP-kriterier oppfylt og verifisert: opprett filen `.claude/DONE`.
## Git
@@ -21,7 +21,7 @@ Du jobber autonomt. Nicolay ser ikke på skjermen før prosjektet er ferdig. Ikk
- Jobb alltid på grener fra `mvp`. Aldri commit eller push til `main`, aldri force-push.
- Én gren per oppgave (`feat/…`, `fix/…`, `test/…`), merge til `mvp` når review og tester er grønne.
- Små commits med Conventional Commits-meldinger på engelsk.
-- Hvis `glab` er innlogget: opprett issue per PLAN-punkt og MR mot `mvp`, reviewer kommenterer i MR-en. Ellers: gjør det samme lokalt og loggfør i `docs/ki-logg.md`.
+- Hvis `glab` er innlogget: opprett issue per PLAN-punkt og MR mot `mvp`, reviewer kommenterer i MR-en. Ellers: gjør det samme lokalt og loggfør i `docs/ki/ki-logg.md`.
## Tekniske rammer (ikke avvik uten å skrive begrunnelse i beslutninger.md)
@@ -44,4 +44,4 @@ Du jobber autonomt. Nicolay ser ikke på skjermen før prosjektet er ferdig. Ikk
- Ingen `sudo`, ingen endringer utenfor repoet, ingen hemmeligheter i git (`.env` i `.gitignore`, `.env.example` i repo).
- README.md skrives av gruppa. Agentene legger bare fakta og installasjonssteg i `docs/`, som gruppa kan bygge README på.
-- Står du fast på noe som krever Nicolay (passord, VM, VPN, tilganger): skriv det i `BLOCKERS.md` og gå videre.
+- Står du fast på noe som krever Nicolay (passord, VM, VPN, tilganger): skriv det i `docs/prosess/blokkeringer.md` og gå videre.
diff --git a/PLAN.md b/PLAN.md
deleted file mode 100644
index 3fef19a..0000000
--- a/PLAN.md
+++ /dev/null
@@ -1,72 +0,0 @@
-# PLAN – MVP
-
-Målet er nådd når alle bokser under er krysset av og verifisert. Lederen krysser av, ikke utviklerne.
-
-## Datasett (standardvalg)
-
-IMDb non-commercial datasets (`title.basics.tsv.gz`, `title.ratings.tsv.gz`) fra https://datasets.imdbws.com – ingen API-nøkkel. Filtrer til filmer og serier med minst 100 stemmer (~100k+ rader). Bilder hoppes over i MVP (bærekraft), evt. placeholders.
-Brukergenererte data: anmeldelser (1–5 stjerner + tekst) og "min liste".
-
-## M0 – Oppsett
-
-- [x] Monorepo med npm workspaces, TypeScript strict, ESLint, Prettier, Husky + lint-staged
-- [x] Scripts i rot: `dev`, `build`, `lint`, `typecheck`, `test`, `test:e2e`, `db:migrate`, `db:seed`
-- [x] `.env.example`, `.gitignore`, `docs/beslutninger.md`, `docs/ki-logg.md`, `BLOCKERS.md`
-- [x] GitLab CI (`.gitlab-ci.yml`): lint, typecheck, unit-tester
-
-## M1 – Database og import
-
-- [x] Skjema + migrasjoner (titles, genres, reviews, lists), indekser inkl. `pg_trgm` GIN på tittel
-- [x] Importskript som strømmer TSV (ikke les alt i minnet) og kan kjøres på nytt (idempotent)
-- [x] `EXPLAIN ANALYZE` for søk før/etter indeks lagret i `docs/ytelse.md`
-
-## M2 – GraphQL-API
-
-- [x] `search(query, filters, sort, first, after)` med connection-type (edges, pageInfo, totalCount)
-- [x] `title(id)` med detaljer og anmeldelser
-- [x] `facets(query, filters)` → antall treff per sjanger / tiår / type
-- [x] Mutations: `addReview`, `toggleList` (anonym bruker-id via cookie/localStorage-uuid)
-- [x] Input-validering, feilhåndtering, maks `first`, query-dybde begrenset
-- [x] API-tester for alle queries/mutations inkl. kanttilfeller
-
-## M3 – Frontend
-
-- [x] Søkefelt med debounce, case-insensitivt, tøm-knapp
-- [x] Resultatliste med uendelig scroll + "Last flere"-knapp, skeleton-lasting
-- [x] Filterpanel (sjanger, tiår, type, min. rating) med antall treff, aktive filtre som chips, nullstill
-- [x] Sortering (relevans, rating, år, tittel) – alltid på server
-- [x] Detaljside med anmeldelser og skjema for ny anmeldelse
-- [x] "Min liste"-side
-- [x] State i URL, tilbake-knapp bevarer scroll
-- [x] Tomt/ingen treff/feil-tilstander med "prøv igjen"
-- [x] Mørk modus (følger system), responsivt ned til 320 px
-
-## M4 – Kvalitet
-
-- [x] Komponenttester for søk, liste, filter, detaljside, anmeldelsesskjema
-- [x] Playwright E2E: søk → filtrer → sorter → scroll → detalj → skriv anmeldelse → se den i lista
-- [x] axe-sjekk i Playwright uten feil på alle sider
-- [x] Lighthouse ≥ 90 på alle kategorier (resultat i `docs/ytelse.md`)
-- [x] Alle tester, lint og typecheck grønne fra ren klon (`npm ci`)
-
-## M5 – Deploy-klargjøring (kjøres ikke mot VM uten Nicolay)
-
-- [x] `docs/deploy.md`: Postgres-oppsett, systemd-fil for backend, Apache-konfig for `/project2`, gzip/cache-headere
-- [x] `deploy/`-mappe med systemd-unit og Apache-konfig ferdig utfylt for `it2810-17.idi.ntnu.no`
-
-## M6 – Se lovlige filmer fra Internet Archive
-
-Kun filmer som Internet Archive markerer som fri bruk (public domain eller Creative Commons). Vi
-lenker til og strømmer fra archive.org og lagrer ingen video selv.
-
-- [x] Backend: tabell `title_streams` (title_id, archive_id, fil, lisens, varighet) + migrering
-- [x] Importskript `db:archive`: hent filmer fra Internet Archive-samlinger med fri lisens, koble til IMDb-titler via IMDb-ID i metadata (ellers tittel + år), velg en MP4-fil som nettlesere kan spille. Idempotent, testet mot falsk Archive-server
-- [x] API: `Title.stream { url, license, archiveUrl }` og filter `availableOnly` (+ fasettantall)
-- [x] Frontend: filter «Kun filmer du kan se», rad «Se gratis nå» på forsiden, merke på plakater
-- [x] Videospiller på `/watch/:id`: spill/pause, spoling (tidslinje og ±10 s), volum og demping, fullskjerm, tastatur (mellomrom, piler, M, F), undertekster når de finnes, lisens og kilde under spilleren
-- [x] Tester: import og kobling, API, spillerkontroller (komponent), E2E med liten testvideo, axe
-- [x] Deploy: `setup-vm.sh` kjører `db:archive` etter importen
-
-## Ferdig
-
-- [x] Oppsummering i `docs/status.md`: hva er gjort, hva gjenstår, hva Nicolay må gjøre
diff --git a/README.md b/README.md
index e7e6cc2..62afa8b 100644
--- a/README.md
+++ b/README.md
@@ -1,78 +1,141 @@
# Filmsøk – IT2810 prosjekt 2, gruppe 17
-Søk, filtrer og anmeld over 190 000 filmer og serier fra IMDb, med plakater fra TMDB.
+Søk, filtrer, sorter og anmeld over 190 000 filmer og serier fra IMDb, med plakater fra TMDB og
+lovlige gratisfilmer fra Internet Archive som kan spilles direkte i appen.
-**🔗 Kjørende versjon:** (NTNU-nett eller VPN)
+**Kjørende versjon:** (krever NTNU-nett eller VPN).
-## Arbeidsfordeling
+
-| Navn | Rolle |
-| ------- | -------------------------------------------------------------------- |
-| Nicolay | Prosjektleder. Arkitektur, GraphQL-API og React, frontend og backend |
-| Strula | Backendansvarlig: database, import og resolvere |
-| Brage | Frontendansvarlig: komponenter, design og tilgjengelighet |
-| Daniel | Testansvarlig: enhets-, komponent- og E2E-tester |
+## Kom i gang
-## Funksjonalitet
+Lokalt, uten VPN. Krever Node.js ≥ 20 og PostgreSQL med contrib-pakken (`pg_trgm`, `unaccent`).
-- Søk på tittel med 300 ms debounce, uten skille på store og små bokstaver
-- Filter på sjanger, tiår, type og rating, med antall treff per valg
-- Sortering på relevans, rating, år og tittel, alltid på serveren
-- Uendelig scroll med «Last flere»-knapp
-- Detaljside, anmeldelser (1–5 stjerner) og «Min liste»
-- Søk og filtre i URL-en, mørk og lys modus, responsivt ned til 320 px
+```bash
+npm ci
+npm run setup # lager databaser og fyller dem med syntetiske data
+npm run dev # http://localhost:5173/project2/
+```
-## Teknologi
+Detaljer, ekte IMDb-data og feilsøking: [`docs/oppsett.md`](docs/oppsett.md#rask-start-2-minutter).
+Tester: `npm test` (backend og frontend) og `npm run test:e2e` (Playwright).
-**Frontend:** React, TypeScript, Vite, Apollo Client · **Backend:** Node.js, GraphQL (Yoga) ·
-**Database:** PostgreSQL med `pg_trgm` · **Test:** Vitest, Testing Library, Playwright, axe
+## Funksjonalitet
-All søk, filtrering og sortering skjer i SQL med cursor-paginering. API-et er beskrevet i
-[`docs/api.md`](docs/api.md).
+| Krav i oppgaven | Slik er det løst |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
+| Søk | 300 ms debounce, ingen request for tomt eller uendret søk, uavhengig av store/små bokstaver og aksenter. Korte søk matcher ordstart |
+| Store resultatsett | Uendelig scroll med «Last flere» som alternativ. Cursor-paginering (keyset) i SQL, 20 per side |
+| Detaljer | Egen side per tittel med handling, sjangre, rating, anmeldelser og eventuell gratisfilm |
+| Sortering og filtrering | Sjanger, tiår, type, minste rating og «gratis». Antall treff per valg. Sortering på relevans, rating, år og tittel. Alt skjer i SQL |
+| Brukergenererte data | Anmeldelser (1–5 stjerner og tekst) og «Min liste», lagret i PostgreSQL. Egne anmeldelser kan slettes |
+| Tilgjengelighet | WCAG 2.1 AA, tastatur overalt, `aria-live` for antall treff |
+| Bærekraft | Lazy-loading, små bilder, cache og komprimering, mørk modus, få avhengigheter |
+| Design | Kinoinspirert, mørkt grensesnitt med lys variant. Bla-modus med rader når søket er tomt, søkemodus med filtre og liste når man søker |
+
+Søketilstanden ligger i URL-en (kan deles og bokmerkes), gratisfilmer spilles i egen spiller med
+tastaturstyring og undertekster, og feiltilstander (tomt søk, ingen treff, nettverksfeil, API nede)
+har egne meldinger.
+
+| | |
+| ------------------------------------------------------------------- | ---------------------------------------------------------------------- |
+|  |  |
+|  |  |
+|  | |
+
+## Arkitektur
-## Pipeline
+```
+Nettleser (React + Apollo Client)
+ │ GET /project2/… statiske filer (Vite-bygg), Apache med gzip og cache-headere
+ │ POST /project2/graphql ──▶ Apache (reverse proxy) ──▶ Node + GraphQL Yoga :3001 (127.0.0.1)
+ │ ├─▶ PostgreSQL (pg_trgm, unaccent)
+ │ └─▶ TMDB API (plakater, bufret i DB)
+ └─ video ──────────────────────────────────────────────────────▶ archive.org (direkte)
+```
```
-feature-gren ─▶ pre-commit ─▶ pull request ─▶ CI ─▶ main ─▶ deploy til VM
+backend/ GraphQL-skjema, resolvere, søk (src/), migreringer og importskript, API-tester mot PostgreSQL
+frontend/ React-app: apollo/ (klient, cache), components/, pages/ (lazy-lastet), hooks/ og lib/
+e2e/ Playwright-tester med axe
+deploy/ systemd-enhet, Apache-konfig, oppsett- og oppdateringsskript for VM-en
+docs/ all dokumentasjon, se docs/README.md
```
-1. **Pre-commit (Husky + lint-staged):** ESLint og Prettier på endrede filer.
-2. **CI (`.gitlab-ci.yml`):** lint, typecheck og enhetstester mot PostgreSQL.
-3. **Review og merge** til `main`.
-4. **Deploy med én kommando:**
- ```bash
- ssh -t @it2810-17.idi.ntnu.no bash T17-Project2/deploy/oppdater.sh
- ```
- Kommandoen henter siste kode, bygger, migrerer databasen, importerer data, henter plakater og
- starter backend (systemd) og Apache på nytt. Den stopper med feilmelding hvis API-et ikke svarer.
+## Viktigste valg
+
+Alle valg med begrunnelse: [`docs/beslutninger.md`](docs/beslutninger.md). Hvordan det henger
+sammen, steg for steg: [`docs/forklaring.md`](docs/forklaring.md).
+
+- **PostgreSQL med `pg_trgm`, GIN og `unaccent`:** delstrengsøk og aksentfolding er indeksert, og
+ målt 3 ms mot 77 ms uten indeks. Tall i [`docs/ytelse.md`](docs/ytelse.md).
+- **Cursor (keyset) i stedet for `OFFSET`:** side 1000 er like rask som side 1, og ingen hull eller
+ duplikater når data endres.
+- **GraphQL (Yoga) med grenser:** klienten henter bare feltene den trenger. Dybde-, kostnads-,
+ `first`- og rate-limit samt maskerte feil beskytter API-et ([`docs/forklaring.md`](docs/forklaring.md#5-vern-av-api-et)).
+- **Apollo Client og URL som kilde til søketilstand:** deling, bokmerker og tilbake-knappen virker
+ uten egen synkronisering.
+- **Tilgjengelighet:** fokusstyring ved rutebytte og sletting, ett `role="alert"`-banner ved nedetid,
+ axe i E2E på alle sider i lys og mørk modus.
+- **Bærekraft:** lazy-lastede ruter og rader, `srcset`, `immutable`-cache, gzip, ingen UI-, spiller-
+ eller ORM-bibliotek.
+- **Ingen SSR:** en statisk SPA bak Apache er enklere å drifte. Prisen er mobil-Lighthouse på 95–97
+ ([begrunnelse](docs/beslutninger.md#hvorfor-ikke-ssr)).
-## Kjøre lokalt
+## Testing
-```bash
-npm ci
-cp .env.example .env
-npm run db:migrate && npm run db:seed # krever IMDb-filene i data/ (se docs/oppsett.md)
-npm run dev # http://localhost:5173/project2/
-```
+| Type | Antall | Kommando |
+| ----------------------------------------------- | -----: | ------------------ |
+| API og database (Vitest mot ekte PostgreSQL) | 490 | `npm test` |
+| Komponenter og hooks (Vitest + Testing Library) | 288 | `npm test` |
+| E2E med axe (Playwright, desktop og mobil) | 101 | `npm run test:e2e` |
-## Testing
+Typisk bruk testes som hele flyter (søk, filtrer, scroll, anmeld, spill film med tastatur). Utypisk
+bruk testes eksplisitt: tomt søk, ingen treff, spesialtegn og SQL-injeksjon, veldig lange strenger,
+ugyldige cursorer, samtidige kall, nettverksfeil og database nede. Oppsettet er beskrevet i
+[`docs/forklaring.md`](docs/forklaring.md#9-testoppsett).
+
+## Deploy på VM
+
+Backend og database kjører direkte på `it2810-17.idi.ntnu.no` uten Docker: PostgreSQL, Node som
+systemd-tjeneste på port 3001 og Apache som serverer frontend under `/project2`. Oppdatering:
+`ssh -t @it2810-17.idi.ntnu.no bash T17-Project2/deploy/oppdater.sh`. Se
+[`docs/deploy.md`](docs/deploy.md).
+
+## Prosess og KI
+
+Prosjektet er utviklet med Claude Code som KI-assistent, styrt av reglene i [`CLAUDE.md`](CLAUDE.md)
+og planen i [`docs/prosess/plan.md`](docs/prosess/plan.md). Hva KI-en har laget, hva vi har
+bestemt og kontrollert selv, og hvilke feil KI-en gjorde, står i
+[`docs/ki/ki-deklarasjon.md`](docs/ki/ki-deklarasjon.md). Arbeidet er gjort på egne grener med
+issues og PR-er med review, se [`docs/prosess/grener-og-pr.md`](docs/prosess/grener-og-pr.md).
+
+## Arbeidsfordeling
+
+| Navn | Ansvarsområde |
+| ------- | -------------------------------------------------------------------- |
+| Nicolay | Prosjektleder. Arkitektur, GraphQL-API og React, frontend og backend |
+| Sturla | Backendansvarlig: database, import og resolvere |
+| Brage | Frontendansvarlig: komponenter, design og tilgjengelighet |
+| Daniel | Testansvarlig: enhets-, komponent- og E2E-tester |
-| Type | Antall | Kommando |
-| ------------------- | -----: | ------------------ |
-| Backend (API og DB) | 209 | `npm test` |
-| Komponenter | 111 | `npm test` |
-| E2E + axe | 48 | `npm run test:e2e` |
+Mye av koden er skrevet av KI-agenter styrt av gruppa (se [Prosess og KI](#prosess-og-ki)),
+så git-historikken viser ikke hvem som har vurdert, testet og godkjent hva. Den enkeltes bidrag er
+beskrevet i egen fil i Canvas.
-E2E-testene dekker hele flyten fra søk til anmeldelse, på desktop og mobil, i lys og mørk
-modus.
+## Kjente begrensninger
-## Dokumentasjon
+- **Ingen innlogging:** anmeldelser og liste følger nettleseren via en anonym ID.
+- **Korte søk (1–2 tegn)** matcher bare starten av ord, av hensyn til ytelsen.
+- **Vanlige ord** som «the» med relevanssortering tar ca. 140 ms.
+- **Ytelsestallene** er målt på syntetiske data (120 000 titler), ikke det ekte datasettet.
+- **Rate limiting ligger i minnet** og nullstilles ved omstart.
+- **VM-en kjører http, ikke https:** Lighthouse Best Practices blir lavere, og brotli og HTTP/2 mangler.
+- **Klient-rendret (ingen SSR):** mobil Performance stopper på 95–97.
-[Oppsett](docs/oppsett.md) · [Deploy](docs/deploy.md) · [API](docs/api.md) ·
-[Beslutninger](docs/beslutninger.md) · [Ytelse](docs/ytelse.md) · [KI-logg](docs/ki-logg.md)
+## Mer dokumentasjon
-Prosjektet er utviklet med Claude Code som KI-assistent. Bruken er logget i
-[`docs/ki-logg.md`](docs/ki-logg.md).
+Indeks over all dokumentasjon: [`docs/README.md`](docs/README.md).
_Bilder og beskrivelser fra TMDB. Produktet bruker TMDB-API-et, men er ikke godkjent eller
-sertifisert av TMDB._
+sertifisert av TMDB. Filmer fra Internet Archive vises med lisens og kilde under spilleren._
diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql
index d5110fe..6a6591d 100644
--- a/backend/migrations/001_init.sql
+++ b/backend/migrations/001_init.sql
@@ -51,7 +51,7 @@ CREATE TABLE list_items (
);
-- Tekstsøk: uttrykkene må være identiske med de i søkespørringen (lower(...)) for at
--- planleggeren skal kunne bruke indeksene.
+-- planleggeren skal kunne bruke indeksene (erstattet av 004_unaccent_search.sql).
CREATE INDEX titles_primary_title_trgm_idx ON titles USING gin (lower(primary_title) gin_trgm_ops);
CREATE INDEX titles_original_title_trgm_idx ON titles USING gin (lower(original_title) gin_trgm_ops);
diff --git a/backend/migrations/004_unaccent_search.sql b/backend/migrations/004_unaccent_search.sql
new file mode 100644
index 0000000..2428707
--- /dev/null
+++ b/backend/migrations/004_unaccent_search.sql
@@ -0,0 +1,43 @@
+-- Aksentuavhengig søk («amelie» finner «Amélie») og ordprefiks for korte søk (1–2 tegn).
+
+CREATE EXTENSION IF NOT EXISTS unaccent;
+
+-- unaccent() er bare STABLE (ordlisten kan byttes), og Postgres krever IMMUTABLE i indeksuttrykk og
+-- genererte kolonner. Standardgrepet er en IMMUTABLE innpakning som peker på ordlisten med fullt
+-- kvalifisert navn, slik at den ikke avhenger av search_path. Ordlisten endres i praksis aldri;
+-- skulle den gjøre det, må kolonnene og indeksene bygges på nytt. Det er IMMUTABLE som lar
+-- planleggeren folde kall med konstant argument (søketeksten) til en konstant, slik at indeksene kan
+-- brukes; STRICT og PARALLEL SAFE er bare ekstra optimalisering.
+CREATE FUNCTION f_unaccent(text) RETURNS text
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
+ AS $$ SELECT public.unaccent('public.unaccent', $1) $$;
+
+-- Normalisert tittel (små bokstaver, uten aksenter) som lagrede, genererte kolonner. Et rent
+-- indeksuttrykk `lower(f_unaccent(primary_title))` ville også virket, men da regnes unaccent ut
+-- på nytt for hver rad som leses i recheck, filter og relevansberegning (målt: ca. 2x tregere på
+-- brede søk som «the»; se docs/ytelse.md). Prisen er ca. 2 x tittellengde ekstra lagring per rad.
+--
+-- Alle tre kolonnene legges til i ÉN ALTER TABLE, fordi hver slik kolonne skriver om hele tabellen
+-- mens den holder ACCESS EXCLUSIVE-lås (ingen lesing eller skriving på titles under migreringen).
+-- Det tar ca. 4 s lokalt på 120 000 titler (regn med 10–20 s på VM); stopp gjerne backend under migreringen (se docs/deploy.md).
+-- title_words er en tsvector av begge titlene til ordprefikssøk (se lenger ned).
+ALTER TABLE titles
+ ADD COLUMN primary_title_norm text GENERATED ALWAYS AS (lower(f_unaccent(primary_title))) STORED,
+ ADD COLUMN original_title_norm text GENERATED ALWAYS AS (lower(f_unaccent(original_title))) STORED,
+ ADD COLUMN title_words tsvector GENERATED ALWAYS AS (
+ to_tsvector('simple', lower(f_unaccent(primary_title)) || ' ' || lower(f_unaccent(original_title)))
+ ) STORED;
+
+-- Trigramindeksene byttes til de normaliserte kolonnene (se src/search.ts).
+DROP INDEX titles_primary_title_trgm_idx;
+DROP INDEX titles_original_title_trgm_idx;
+CREATE INDEX titles_primary_title_trgm_idx
+ ON titles USING gin (primary_title_norm gin_trgm_ops);
+CREATE INDEX titles_original_title_trgm_idx
+ ON titles USING gin (original_title_norm gin_trgm_ops);
+
+-- Søk på 1–2 tegn har ingen trigrammer. De gjøres om til ordprefiks («ma» finner «The Matrix»)
+-- mot en lagret tsvector av begge titlene, med GIN. 'simple' (ingen stemming eller stoppord) fordi
+-- titler ikke er prosa og «the» skal kunne søkes på. Dette erstatter prefiksindekser på hele
+-- tittelen, som bare fant titler som BEGYNTE med søketeksten.
+CREATE INDEX titles_title_words_idx ON titles USING gin (title_words);
diff --git a/backend/package.json b/backend/package.json
index d913daa..def09f1 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -14,6 +14,7 @@
"db:seed": "tsx scripts/import-imdb.ts",
"db:fixture": "tsx scripts/generate-fixture.ts",
"db:artwork": "tsx scripts/prefetch-artwork.ts",
+ "db:measure": "tsx scripts/measure-performance.ts",
"db:archive": "tsx scripts/import-archive.ts",
"tmdb:mock": "tsx scripts/tmdb-mock.ts",
"archive:mock": "tsx scripts/archive-mock.ts"
diff --git a/backend/scripts/dev.test.ts b/backend/scripts/dev.test.ts
new file mode 100644
index 0000000..a305c5d
--- /dev/null
+++ b/backend/scripts/dev.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+import { planDev } from './dev.js';
+
+const names = (env: NodeJS.ProcessEnv) => planDev(env).commands.map((c) => c.name);
+const backendEnv = (env: NodeJS.ProcessEnv) =>
+ planDev(env).commands.find((c) => c.name === 'backend')?.env;
+
+describe('planDev', () => {
+ it('starts both mocks and points the backend at them when TMDB_API_KEY is empty', () => {
+ const plan = planDev({ TMDB_API_KEY: '' });
+ expect(plan.mocks).toBe(true);
+ expect(names({ TMDB_API_KEY: '' })).toEqual(['backend', 'tmdb', 'archive', 'frontend']);
+ expect(backendEnv({})).toEqual({
+ TMDB_API_KEY: 'test',
+ TMDB_API_URL: 'http://localhost:3999/3',
+ TMDB_IMAGE_URL: 'http://localhost:3999/t/p',
+ ARCHIVE_URL: 'http://localhost:3998',
+ });
+ expect(plan.ports.map((p) => p.port)).toEqual([3001, 3999, 3998]);
+ });
+
+ it('treats a whitespace-only key as empty', () => {
+ expect(planDev({ TMDB_API_KEY: ' ' }).mocks).toBe(true);
+ });
+
+ it('runs as before with a real key: no mocks and no overridden URLs', () => {
+ const env = { TMDB_API_KEY: 'abc123' };
+ const plan = planDev(env);
+ expect(plan.mocks).toBe(false);
+ expect(names(env)).toEqual(['backend', 'frontend']);
+ expect(backendEnv(env)).toBeUndefined();
+ expect(plan.ports).toEqual([{ name: 'backend', port: 3001 }]);
+ });
+
+ it('honours custom ports', () => {
+ const env = { TMDB_MOCK_PORT: '4999', ARCHIVE_MOCK_PORT: '4998', PORT: '4001' };
+ const plan = planDev(env);
+ expect(backendEnv(env)).toMatchObject({
+ TMDB_API_URL: 'http://localhost:4999/3',
+ ARCHIVE_URL: 'http://localhost:4998',
+ });
+ expect(plan.ports.map((p) => p.port)).toEqual([4001, 4999, 4998]);
+ });
+
+ it('falls back to default ports for garbage values', () => {
+ const ports = planDev({ TMDB_MOCK_PORT: 'abc', ARCHIVE_MOCK_PORT: '-5' }).ports;
+ expect(ports.map((p) => p.port)).toEqual([3001, 3999, 3998]);
+ });
+
+ it('keeps an explicit ARCHIVE_URL and skips the archive mock', () => {
+ const env = { ARCHIVE_URL: 'https://archive.org' };
+ expect(names(env)).toEqual(['backend', 'tmdb', 'frontend']);
+ expect(backendEnv(env)).not.toHaveProperty('ARCHIVE_URL');
+ });
+
+ it('uses only portable command strings (no shell-specific env syntax)', () => {
+ for (const c of planDev({}).commands) {
+ expect(c.command).toMatch(/^npm run [\w:]+ -w (backend|frontend)$/);
+ }
+ });
+});
diff --git a/backend/scripts/dev.ts b/backend/scripts/dev.ts
new file mode 100644
index 0000000..a4f2d34
--- /dev/null
+++ b/backend/scripts/dev.ts
@@ -0,0 +1,127 @@
+import { createServer } from 'node:net';
+import { pathToFileURL } from 'node:url';
+import concurrently from 'concurrently';
+
+/**
+ * `npm run dev`: backend og frontend sammen. Uten TMDB_API_KEY startes i tillegg de falske TMDB- og
+ * Archive-serverne, slik at plakater og gratisfilmer vises uten nøkler og uten internett. Valget
+ * gjøres her og ikke i package.json, for `VAR=verdi kommando` og `${VAR:-...}` fungerer ikke i cmd.exe.
+ */
+
+const DEFAULT_TMDB_MOCK_PORT = 3999;
+const DEFAULT_ARCHIVE_MOCK_PORT = 3998;
+
+export interface PlannedCommand {
+ name: string;
+ command: string;
+ prefixColor: string;
+ env?: Record;
+}
+
+export interface DevPlan {
+ /** True når de falske tjenestene brukes (ingen TMDB-nøkkel). */
+ mocks: boolean;
+ commands: PlannedCommand[];
+ /** Porter som må være ledige, for en tydelig feilmelding før noe starter. */
+ ports: { name: string; port: number }[];
+}
+
+function port(value: string | undefined, fallback: number): number {
+ const n = Number.parseInt(value ?? '', 10);
+ return Number.isInteger(n) && n > 0 ? n : fallback;
+}
+
+/**
+ * Bestemmer hvilke prosesser som startes og hvilke miljøvariabler backend får. `env` er miljøet etter at
+ * .env er lest. Ren funksjon, slik at valget kan testes uten å starte noe.
+ */
+export function planDev(env: NodeJS.ProcessEnv): DevPlan {
+ const backendPort = port(env.PORT, 3001);
+ const ports = [{ name: 'backend', port: backendPort }];
+ const commands: PlannedCommand[] = [];
+ const hasKey = !!env.TMDB_API_KEY?.trim();
+
+ if (hasKey) {
+ commands.push({ name: 'backend', command: 'npm run dev -w backend', prefixColor: 'blue' });
+ } else {
+ const tmdbPort = port(env.TMDB_MOCK_PORT, DEFAULT_TMDB_MOCK_PORT);
+ const backendEnv: Record = {
+ // Backend gjør bare nettverkskall når nøkkelen er satt; verdien er ellers uten betydning for mocken.
+ TMDB_API_KEY: 'test',
+ TMDB_API_URL: `http://localhost:${tmdbPort}/3`,
+ TMDB_IMAGE_URL: `http://localhost:${tmdbPort}/t/p`,
+ };
+ ports.push({ name: 'tmdb-mock', port: tmdbPort });
+ commands.push({
+ name: 'tmdb',
+ command: 'npm run tmdb:mock -w backend',
+ prefixColor: 'yellow',
+ env: { TMDB_MOCK_PORT: String(tmdbPort) },
+ });
+
+ // Satt ARCHIVE_URL er et bevisst valg (for eksempel ekte archive.org) og respekteres.
+ if (!env.ARCHIVE_URL?.trim()) {
+ const archivePort = port(env.ARCHIVE_MOCK_PORT, DEFAULT_ARCHIVE_MOCK_PORT);
+ backendEnv.ARCHIVE_URL = `http://localhost:${archivePort}`;
+ ports.push({ name: 'archive-mock', port: archivePort });
+ commands.push({
+ name: 'archive',
+ command: 'npm run archive:mock -w backend',
+ prefixColor: 'cyan',
+ env: { ARCHIVE_MOCK_PORT: String(archivePort) },
+ });
+ }
+ commands.unshift({
+ name: 'backend',
+ command: 'npm run dev -w backend',
+ prefixColor: 'blue',
+ env: backendEnv,
+ });
+ }
+
+ commands.push({ name: 'frontend', command: 'npm run dev -w frontend', prefixColor: 'magenta' });
+ return { mocks: !hasKey, commands, ports };
+}
+
+function isPortFree(p: number): Promise {
+ return new Promise((resolve) => {
+ const server = createServer();
+ server.once('error', () => resolve(false));
+ server.listen(p, () => server.close(() => resolve(true)));
+ });
+}
+
+async function main() {
+ // Importen leser .env som en bivirkning; må skje før planDev ser på process.env.
+ await import('../src/config.js');
+ const plan = planDev(process.env);
+
+ const busy: string[] = [];
+ for (const { name, port: p } of plan.ports) {
+ if (!(await isPortFree(p))) busy.push(`${name} (port ${p})`);
+ }
+ if (busy.length) {
+ console.error(
+ `Port(er) i bruk: ${busy.join(', ')}.\n` +
+ 'Stopp prosessen som bruker dem (kanskje en annen `npm run dev` eller E2E-kjøring), og prøv igjen.',
+ );
+ process.exit(1);
+ }
+
+ console.log(
+ plan.mocks
+ ? 'TMDB_API_KEY er tom: starter falsk TMDB og falsk Internet Archive sammen med appen.'
+ : 'TMDB_API_KEY er satt: bruker ekte TMDB.',
+ );
+ // Når én prosess krasjer (f.eks. databasen er nede) skal resten stoppes, ellers ser det ut som
+ // appen kjører mens den ikke virker.
+ const { result } = concurrently(plan.commands, { killOthersOn: ['failure'] });
+ await result.catch(() => process.exit(1));
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((err) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+ });
+}
diff --git a/backend/scripts/import-archive.test.ts b/backend/scripts/import-archive.test.ts
index f73c85b..3820e67 100644
--- a/backend/scripts/import-archive.test.ts
+++ b/backend/scripts/import-archive.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import {
buildTitleIndex,
+ CANDIDATE_QUERY,
+ CURATED_COLLECTIONS,
chooseFiles,
extractImdbId,
extractYear,
@@ -254,3 +256,15 @@ describe('chooseFiles', () => {
expect(chooseFiles([f('film.mp4', 'h.264'), f('a.srt', 'SubRip')])?.subtitlesFile).toBeNull();
});
});
+
+describe('CANDIDATE_QUERY', () => {
+ it('tar med alle kuraterte samlinger uansett lisens', () => {
+ for (const c of CURATED_COLLECTIONS) expect(CANDIDATE_QUERY).toContain(`collection:${c} OR`);
+ });
+
+ it('krever IMDb-referanse for lisensbaserte treff, så hjemmevideoer ikke skannes', () => {
+ expect(CANDIDATE_QUERY).toMatch(
+ /\(\(licenseurl:\*creativecommons\* OR licenseurl:\*publicdomain\*\) AND external-identifier:\*imdb\*\)/,
+ );
+ });
+});
diff --git a/backend/scripts/import-archive.ts b/backend/scripts/import-archive.ts
index 1ef1b58..f2b9b25 100644
--- a/backend/scripts/import-archive.ts
+++ b/backend/scripts/import-archive.ts
@@ -278,8 +278,15 @@ export function chooseFiles(files: unknown): ChosenFile | null {
// --- HTTP ---------------------------------------------------------------------------------
+/**
+ * Kuraterte samlinger tas med uansett. Lisensbaserte treff krever i tillegg en IMDb-referanse:
+ * av ca. 259 000 Creative Commons-elementer ble under 1 % koblet til en IMDb-film (resten er
+ * hjemmevideoer o.l.), og å skanne alle tok 10-30 minutter. `classic_tv` er utelatt fordi
+ * samlingen bare er lovlig med lisens-URL, og de treffene dekkes av lisensgrenen.
+ */
export const CANDIDATE_QUERY =
- 'mediatype:movies AND (collection:feature_films OR collection:film_noir OR collection:silent_films OR collection:classic_tv OR licenseurl:*creativecommons* OR licenseurl:*publicdomain*)';
+ 'mediatype:movies AND (collection:feature_films OR collection:film_noir OR collection:silent_films OR ' +
+ '((licenseurl:*creativecommons* OR licenseurl:*publicdomain*) AND external-identifier:*imdb*))';
const SCRAPE_FIELDS = 'identifier,title,year,date,licenseurl,external-identifier,collection';
@@ -370,6 +377,12 @@ export interface ImportOptions {
retryDelayMs?: number;
pageSize?: number;
fetch?: typeof fetch;
+ /**
+ * Første Ctrl+C: slutt å lete etter nye kandidater, men lagre dem som allerede er koblet.
+ * Leses mellom elementene, så et pågående Scrape-kall får fullføre.
+ */
+ stopCollecting?: { aborted: boolean };
+ /** Andre Ctrl+C: avbryt alt, også lagringen. Leses mellom kandidatene. */
signal?: { aborted: boolean };
log?: (msg: string) => void;
}
@@ -408,10 +421,52 @@ export async function importArchive(opts: ImportOptions): Promise
}>("SELECT id, primary_title, original_title, start_year FROM titles WHERE title_type = 'movie'");
const titles = buildTitleIndex(movieRows);
- // 1) Samle kandidater fra Scrape API (billig: 1000 per kall) og koble dem.
+ // Utfall per IMDb-tittel. Tellerne i summary utledes herfra til slutt, slik at en tittel som
+ // lagres to ganger (se «IMDb-ID slår tittelkobling» under) bare telles én gang.
+ const outcome = new Map();
+ let processed = 0;
+
+ // Henter filliste per koblet kandidat og lagrer. Kjøres per Scrape-side, så en lang skanning
+ // (10-30 min mot hele Archive) ikke mister alt ved avbrudd eller feil underveis.
+ const store = async (list: Candidate[]) => {
+ let next = 0;
+ const worker = async () => {
+ while (!opts.signal?.aborted) {
+ const cand = list[next++];
+ if (!cand) return;
+ const id = cand.item.identifier;
+ const titleId = cand.link.titleId;
+ try {
+ const meta = (await getJson(`${base}/metadata/${encodeURIComponent(id)}`)) as {
+ files?: unknown;
+ } | null;
+ const chosen = chooseFiles(meta?.files);
+ if (!chosen) {
+ outcome.set(titleId, 'noFile');
+ log(`Hopper over ${id}: ingen spillbar fil.`);
+ } else {
+ await upsert(pool, cand, chosen);
+ outcome.set(titleId, 'imported');
+ }
+ } catch (err) {
+ outcome.set(titleId, 'failed');
+ log(`Feilet for ${id}: ${(err as Error).message}`);
+ }
+ if (++processed % 25 === 0) log(`${processed} kandidater behandlet.`);
+ }
+ };
+ await Promise.all(Array.from({ length: opts.concurrency ?? 4 }, worker));
+ };
+
+ // Samle kandidater fra Scrape API (billig: 1000 per kall), koble dem og lagre side for side.
+ // Flere Archive-elementer for samme film: IMDb-ID slår tittelkobling, ellers vinner det første,
+ // så en ny kjøring gir samme utfall. Kommer IMDb-treffet først på en senere side, upsertes det
+ // over tittelkoblingen som allerede er lagret.
const claimed = new Map();
let cursor: string | undefined;
- collect: do {
+ let stop = false;
+ do {
+ if (opts.signal?.aborted || opts.stopCollecting?.aborted) break;
const url = new URL(`${base}/services/search/v1/scrape`);
url.searchParams.set('q', CANDIDATE_QUERY);
url.searchParams.set('fields', SCRAPE_FIELDS);
@@ -433,8 +488,21 @@ export async function importArchive(opts: ImportOptions): Promise
for (const r of rows) existing.add(r.id);
}
+ // Nye eller erstattede kandidater fra denne siden; lagres når siden er ferdig.
+ const fresh = new Map();
for (const item of items) {
- if (opts.signal?.aborted) break collect;
+ if (opts.signal?.aborted) {
+ stop = true;
+ break;
+ }
+ if (opts.stopCollecting?.aborted) {
+ stop = true;
+ log(
+ `Avbrutt: lagrer ${fresh.size} filmer som allerede er funnet. ` +
+ 'Trykk Ctrl+C igjen for å avbryte helt.',
+ );
+ break;
+ }
summary.scanned++;
if (typeof item.identifier !== 'string' || !isSafeArchiveId(item.identifier)) continue;
if (!isLawful(item)) {
@@ -446,49 +514,29 @@ export async function importArchive(opts: ImportOptions): Promise
summary.unlinked++;
continue;
}
- // Flere Archive-elementer for samme film: IMDb-ID slår tittelkobling, ellers vinner det
- // første, så en ny kjøring gir samme utfall.
const prev = claimed.get(link.titleId);
if (!prev || (prev.link.via === 'title' && link.via === 'imdb')) {
- claimed.set(link.titleId, { item, link });
+ const cand = { item, link };
+ claimed.set(link.titleId, cand);
+ fresh.set(link.titleId, cand);
}
if (prev) summary.duplicates++;
- if (opts.limit !== undefined && claimed.size >= opts.limit) break collect;
+ if (opts.limit !== undefined && claimed.size >= opts.limit) {
+ stop = true;
+ break;
+ }
}
+ await store([...fresh.values()]);
cursor =
typeof page.cursor === 'string' && page.cursor && items.length > 0 ? page.cursor : undefined;
log(`Skannet ${summary.scanned} elementer, ${claimed.size} koblet til IMDb-titler.`);
- } while (cursor);
-
- // 2) Hent filliste per koblet kandidat og lagre.
- const todo = [...claimed.values()];
- let next = 0;
- let done = 0;
- const worker = async () => {
- while (!opts.signal?.aborted) {
- const cand = todo[next++];
- if (!cand) return;
- const id = cand.item.identifier;
- try {
- const meta = (await getJson(`${base}/metadata/${encodeURIComponent(id)}`)) as {
- files?: unknown;
- } | null;
- const chosen = chooseFiles(meta?.files);
- if (!chosen) {
- summary.noFile++;
- log(`Hopper over ${id}: ingen spillbar fil.`);
- } else {
- await upsert(pool, cand, chosen);
- summary.imported++;
- }
- } catch (err) {
- summary.failed++;
- log(`Feilet for ${id}: ${(err as Error).message}`);
- }
- if (++done % 25 === 0) log(`${done}/${todo.length} behandlet.`);
- }
- };
- await Promise.all(Array.from({ length: opts.concurrency ?? 4 }, worker));
+ } while (cursor && !stop);
+
+ for (const o of outcome.values()) {
+ if (o === 'imported') summary.imported++;
+ else if (o === 'noFile') summary.noFile++;
+ else summary.failed++;
+ }
return summary;
}
@@ -519,14 +567,27 @@ async function main() {
const limitText = arg >= 0 ? process.argv[arg + 1] : process.env.ARCHIVE_LIMIT;
const limit = Number.parseInt(limitText ?? '', 10) || undefined;
const pool = createPool(config.databaseUrl);
+ // To trinn: første Ctrl+C stopper skanningen men lagrer det som er funnet, andre avbryter helt.
+ const stopCollecting = { aborted: false };
const signal = { aborted: false };
process.on('SIGINT', () => {
- console.log('\nAvbryter etter pågående kall …');
- signal.aborted = true;
+ if (!stopCollecting.aborted) {
+ console.log('\nStopper skanningen etter pågående kall …');
+ stopCollecting.aborted = true;
+ } else {
+ console.log('\nAvbryter etter pågående kall …');
+ signal.aborted = true;
+ }
});
console.log(`Henter fra ${config.archiveUrl}${limit ? ` (maks ${limit} titler)` : ''}`);
try {
- const s = await importArchive({ pool, ...(limit ? { limit } : {}), signal, log: console.log });
+ const s = await importArchive({
+ pool,
+ ...(limit ? { limit } : {}),
+ stopCollecting,
+ signal,
+ log: console.log,
+ });
console.log(
`Ferdig: ${s.imported} lagret, ${s.unlawful} uten fri lisens, ${s.unlinked} uten IMDb-kobling, ` +
`${s.duplicates} duplikater, ${s.noFile} uten spillbar fil, ${s.failed} feilet (av ${s.scanned} skannet).`,
diff --git a/backend/scripts/import-imdb.ts b/backend/scripts/import-imdb.ts
index 53f12ce..b620227 100644
--- a/backend/scripts/import-imdb.ts
+++ b/backend/scripts/import-imdb.ts
@@ -61,11 +61,15 @@ async function loadRatings(file: string, minVotes: number, log: (m: string) => v
* Upsert av én batch. Arrays sendes som én parameter per kolonne og pakkes ut med unnest():
* få parametere uansett batch-størrelse, og data blir aldri limt inn i SQL-teksten.
*/
-async function upsertBatch(pool: Pool, rows: TitleRow[]): Promise {
+async function upsertBatch(pool: Pool, allRows: TitleRow[]): Promise {
const client = await pool.connect();
try {
await client.query('BEGIN');
- await client.query(
+ // WHERE ... IS DISTINCT FROM: en omkjøring uten nye data skal ikke skrive en eneste rad. Uten
+ // vilkåret lager Postgres en ny radversjon for hver eksisterende tittel (bloat som må vakuumeres),
+ // regner ut de genererte kolonnene (unaccent, tsvector) på nytt og oppdaterer GIN-indeksene.
+ // Bare rader som faktisk ble satt inn eller endret kommer tilbake fra RETURNING.
+ const { rows: changed } = await client.query<{ id: string }>(
`INSERT INTO titles (id, title_type, primary_title, original_title, start_year, end_year,
runtime_minutes, average_rating, num_votes, genres)
SELECT id, title_type, primary_title, original_title, start_year, end_year,
@@ -84,24 +88,39 @@ async function upsertBatch(pool: Pool, rows: TitleRow[]): Promise {
runtime_minutes = EXCLUDED.runtime_minutes,
average_rating = EXCLUDED.average_rating,
num_votes = EXCLUDED.num_votes,
- genres = EXCLUDED.genres`,
+ genres = EXCLUDED.genres
+ WHERE (titles.title_type, titles.primary_title, titles.original_title, titles.start_year,
+ titles.end_year, titles.runtime_minutes, titles.average_rating, titles.num_votes,
+ titles.genres)
+ IS DISTINCT FROM
+ (EXCLUDED.title_type, EXCLUDED.primary_title, EXCLUDED.original_title,
+ EXCLUDED.start_year, EXCLUDED.end_year, EXCLUDED.runtime_minutes,
+ EXCLUDED.average_rating, EXCLUDED.num_votes, EXCLUDED.genres)
+ RETURNING id`,
[
- rows.map((r) => r.id),
- rows.map((r) => r.titleType),
- rows.map((r) => r.primaryTitle),
- rows.map((r) => r.originalTitle),
- rows.map((r) => r.startYear),
- rows.map((r) => r.endYear),
- rows.map((r) => r.runtimeMinutes),
- rows.map((r) => r.averageRating),
- rows.map((r) => r.numVotes),
+ allRows.map((r) => r.id),
+ allRows.map((r) => r.titleType),
+ allRows.map((r) => r.primaryTitle),
+ allRows.map((r) => r.originalTitle),
+ allRows.map((r) => r.startYear),
+ allRows.map((r) => r.endYear),
+ allRows.map((r) => r.runtimeMinutes),
+ allRows.map((r) => r.averageRating),
+ allRows.map((r) => r.numVotes),
// Sjangernavn inneholder aldri komma (IMDb skiller dem med komma).
- rows.map((r) => r.genres.join(',')),
+ allRows.map((r) => r.genres.join(',')),
],
);
- // Normalisert kopi: nye sjangre, og title_genres bygges på nytt for titlene i batchen
- // slik at en endret sjangerliste ved omkjøring ikke etterlater gamle koblinger.
+ // Normalisert kopi: nye sjangre, og title_genres bygges på nytt for titlene som ble satt inn
+ // eller endret, slik at en endret sjangerliste ved omkjøring ikke etterlater gamle koblinger.
+ // Uendrede titler har allerede riktige koblinger (samme transaksjon som titles-raden).
+ const changedIds = new Set(changed.map((r) => r.id));
+ const rows = allRows.filter((r) => changedIds.has(r.id));
+ if (rows.length === 0) {
+ await client.query('COMMIT');
+ return;
+ }
const pairsTitle: string[] = [];
const pairsGenre: string[] = [];
for (const r of rows) {
@@ -178,7 +197,9 @@ export async function runImport(pool: Pool, opts: ImportOptions): Promise<{ impo
}
async function main() {
- const pool = createPool(config.databaseUrl);
+ // Én batch på 2 000 rader oppdaterer tre GIN-indekser og kan ta flere sekunder på trege disker;
+ // API-ets 15 s-grense er for stram for en førstegangsinnlasting.
+ const pool = createPool(config.databaseUrl, { statementTimeoutMs: 10 * 60_000 });
try {
await migrate(pool, console.log);
await runImport(pool, {
diff --git a/backend/scripts/measure-performance.test.ts b/backend/scripts/measure-performance.test.ts
new file mode 100644
index 0000000..b8cc4e5
--- /dev/null
+++ b/backend/scripts/measure-performance.test.ts
@@ -0,0 +1,199 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { config } from '../src/config.js';
+import { createPool, type Pool } from '../src/db.js';
+import { resetData, TITLE_COUNT } from '../test/fixtures.js';
+import {
+ capturePool,
+ describeNode,
+ explainStatement,
+ formatReport,
+ measure,
+ median,
+ parsePlan,
+ SCENARIOS,
+ type PlanNode,
+} from './measure-performance.js';
+
+const indexScan: PlanNode = {
+ 'Node Type': 'Index Scan',
+ 'Index Name': 'titles_rating_idx',
+ 'Relation Name': 'titles',
+ 'Actual Total Time': 0.9,
+ 'Actual Loops': 1,
+};
+
+describe('parsePlan', () => {
+ it('velger noden med mest egentid, ikke den øverste', () => {
+ const plan = parsePlan([
+ {
+ Plan: {
+ 'Node Type': 'Limit',
+ 'Actual Total Time': 1.0,
+ 'Actual Loops': 1,
+ Plans: [indexScan],
+ },
+ 'Execution Time': 1.2,
+ },
+ ]);
+ expect(plan).toEqual({ executionMs: 1.2, mainNode: 'Index Scan using titles_rating_idx' });
+ });
+
+ it('regner tid over alle sløyfer', () => {
+ const plan = parsePlan([
+ {
+ Plan: {
+ 'Node Type': 'Nested Loop',
+ 'Actual Total Time': 10,
+ 'Actual Loops': 1,
+ Plans: [
+ {
+ 'Node Type': 'Seq Scan',
+ 'Relation Name': 'a',
+ 'Actual Total Time': 1,
+ 'Actual Loops': 1,
+ },
+ // 0,5 ms x 10 sløyfer = 5 ms, mer enn Seq Scan og Nested Loop selv (4 ms).
+ {
+ 'Node Type': 'Index Scan',
+ 'Index Name': 'b_pkey',
+ 'Actual Total Time': 0.5,
+ 'Actual Loops': 10,
+ },
+ ],
+ },
+ 'Execution Time': 10.5,
+ },
+ ]);
+ expect(plan.mainNode).toBe('Index Scan using b_pkey');
+ });
+
+ it('kaster tydelig feil på uventet format', () => {
+ expect(() => parsePlan(null)).toThrow(/EXPLAIN/);
+ expect(() => parsePlan([{ Plan: indexScan }])).toThrow(/Execution Time/);
+ expect(() => parsePlan([])).toThrow(/EXPLAIN/);
+ });
+});
+
+describe('describeNode', () => {
+ it('lister indeksene en bitmapskanning kombinerer, uten duplikater', () => {
+ const node: PlanNode = {
+ 'Node Type': 'Bitmap Heap Scan',
+ 'Relation Name': 'titles',
+ Plans: [
+ {
+ 'Node Type': 'BitmapOr',
+ Plans: [
+ { 'Node Type': 'Bitmap Index Scan', 'Index Name': 'a_trgm' },
+ { 'Node Type': 'Bitmap Index Scan', 'Index Name': 'b_trgm' },
+ { 'Node Type': 'Bitmap Index Scan', 'Index Name': 'a_trgm' },
+ ],
+ },
+ ],
+ };
+ expect(describeNode(node)).toBe('Bitmap Heap Scan on titles (a_trgm, b_trgm)');
+ });
+
+ it('viser sorteringsmetode og tabell når det ikke er noen indeks', () => {
+ expect(describeNode({ 'Node Type': 'Sort', 'Sort Method': 'top-N heapsort' })).toBe(
+ 'Sort (top-N heapsort)',
+ );
+ expect(describeNode({ 'Node Type': 'Seq Scan', 'Relation Name': 'titles' })).toBe(
+ 'Seq Scan on titles',
+ );
+ expect(describeNode({ 'Node Type': 'Aggregate' })).toBe('Aggregate');
+ });
+});
+
+describe('median', () => {
+ it('tar midtverdien og ignorerer rekkefølgen', () => {
+ expect(median([5, 1, 3])).toBe(3);
+ expect(median([4, 1, 3, 2])).toBe(2.5);
+ expect(median([7])).toBe(7);
+ });
+
+ it('er upåvirket av én ekstrem kjøring', () => {
+ expect(median([1.1, 1.0, 1.2, 250, 1.1])).toBe(1.1);
+ });
+
+ it('kaster på tom liste', () => {
+ expect(() => median([])).toThrow();
+ });
+});
+
+describe('formatReport', () => {
+ it('lager en markdown-tabell og escaper rørtegn i cellene', () => {
+ const md = formatReport(
+ { titleCount: 190000, pgVersion: '16.4', date: '2026-10-08', runs: 5, jit: 'off' },
+ [
+ { name: 'Søk «a|b»', ms: 3.14159, mainNode: 'Index Scan using x', statementCount: 1 },
+ { name: 'Fasetter', ms: 120.56, mainNode: 'Seq Scan on titles', statementCount: 4 },
+ ],
+ );
+ expect(md).toContain('2026-10-08');
+ expect(md).toContain('PostgreSQL 16.4');
+ expect(md).toContain('| Scenario | ms (median) | Planens hovednode |');
+ expect(md).toContain('| Søk «a\\|b» | 3,14 | Index Scan using x |');
+ expect(md).toContain('| Fasetter, sum av 4 spørringer | 120,6 | Seq Scan on titles |');
+ });
+});
+
+describe('capturePool', () => {
+ it('fanger SQL og parametere uten å røre en database', async () => {
+ const { pool, captured } = capturePool();
+ await pool.query('SELECT $1', ['x']);
+ expect(captured).toEqual([{ text: 'SELECT $1', values: ['x'] }]);
+ });
+});
+
+describe('mot testdatabasen', () => {
+ let pool: Pool;
+ beforeAll(async () => {
+ pool = createPool(config.testDatabaseUrl);
+ await resetData(pool);
+ });
+ afterAll(async () => {
+ await pool.end();
+ });
+
+ it('måler alle scenarioene og gir en rad per scenario', async () => {
+ const results = await measure(pool, SCENARIOS, { runs: 2, warmups: 0 });
+ expect(results).toHaveLength(SCENARIOS.length);
+ for (const r of results) {
+ expect(Number.isFinite(r.ms)).toBe(true);
+ expect(r.ms).toBeGreaterThanOrEqual(0);
+ expect(r.mainNode.length).toBeGreaterThan(0);
+ }
+ // Fasettene er fire spørringer (sjangre, tiår, typer, tilgjengelige); resten er én.
+ expect(results.find((r) => r.name.startsWith('Fasetter'))?.statementCount).toBe(4);
+ expect(results.filter((r) => r.statementCount === 1)).toHaveLength(SCENARIOS.length - 1);
+ // Fixture-datasettet har langt færre enn 50 sider; tabellen skal si det i stedet for å late som.
+ expect(TITLE_COUNT).toBeLessThan(50 * 20);
+ expect(results.find((r) => r.name.startsWith('Side 50'))?.name).toMatch(/rakk bare til side/);
+
+ const md = formatReport(
+ { titleCount: TITLE_COUNT, pgVersion: '16', date: '2026-10-08', runs: 2, jit: 'off' },
+ results,
+ );
+ const tableRows = md
+ .split('\n')
+ .filter((l) => l.startsWith('| ') && !l.startsWith('| Scenario'));
+ // Skilletegnraden («| --- |») kommer i tillegg til én rad per scenario.
+ expect(tableRows).toHaveLength(SCENARIOS.length + 1);
+ });
+
+ it('avviser setninger som ikke er SELECT', async () => {
+ await expect(
+ explainStatement(pool, { text: 'DELETE FROM titles', values: [] }),
+ ).rejects.toThrow(/SELECT/);
+ const { rows } = await pool.query<{ n: string }>('SELECT count(*) AS n FROM titles');
+ expect(Number(rows[0]?.n)).toBe(TITLE_COUNT);
+ });
+
+ it('kan måle spørringer med parametere og spesialtegn', async () => {
+ const plan = await explainStatement(pool, {
+ text: 'SELECT id FROM titles WHERE primary_title_norm LIKE $1',
+ values: ["%'; DROP TABLE titles; --%"],
+ });
+ expect(plan.executionMs).toBeGreaterThanOrEqual(0);
+ });
+});
diff --git a/backend/scripts/measure-performance.ts b/backend/scripts/measure-performance.ts
new file mode 100644
index 0000000..1fc83d9
--- /dev/null
+++ b/backend/scripts/measure-performance.ts
@@ -0,0 +1,353 @@
+import { pathToFileURL } from 'node:url';
+import { config } from '../src/config.js';
+import { createPool, type Pool } from '../src/db.js';
+import { getFacets, searchTitles, type SearchArgs, type SortField } from '../src/search.js';
+import type { Filters } from '../src/validation.js';
+
+/**
+ * Måler søkespørringene mot en ekte database og skriver en markdown-tabell til docs/ytelse.md.
+ * Selve SQL-en kopieres ikke hit: den fanges opp fra search.ts ved å gi funksjonene en «pool» som
+ * bare skriver ned det den får, slik at tallene alltid gjelder nøyaktig de spørringene API-et
+ * bygger, også etter at search.ts endres. Kjøres på VM-en via deploy/mal-ytelse.sh.
+ */
+
+export interface Statement {
+ text: string;
+ values: unknown[];
+}
+
+/** Gir search.ts noe som ser ut som en Pool, men som bare registrerer spørringene (og svarer tomt). */
+export function capturePool(): { pool: Pool; captured: Statement[] } {
+ const captured: Statement[] = [];
+ const pool = {
+ query: (text: string, values: unknown[] = []) => {
+ captured.push({ text, values });
+ return Promise.resolve({ rows: [] });
+ },
+ } as unknown as Pool;
+ return { pool, captured };
+}
+
+export interface PlanNode {
+ 'Node Type': string;
+ 'Index Name'?: string;
+ 'Relation Name'?: string;
+ 'Sort Method'?: string;
+ 'Actual Total Time'?: number;
+ 'Actual Loops'?: number;
+ Plans?: PlanNode[];
+}
+
+/** Total tid en node brukte (alle sløyfer), inkludert barna. */
+const totalTime = (n: PlanNode) => (n['Actual Total Time'] ?? 0) * (n['Actual Loops'] ?? 1);
+
+function* walk(node: PlanNode): Generator {
+ yield node;
+ for (const child of node.Plans ?? []) yield* walk(child);
+}
+
+/**
+ * Tid noden brukte selv (uten barna). Hovednoden er den med mest egentid: «Limit» og «Sort» over
+ * en indeksskanning bruker nesten ingenting selv, og skal ikke bli stående som planens
+ * «hovednode» bare fordi de ligger øverst.
+ */
+const selfTime = (n: PlanNode) =>
+ Math.max(0, totalTime(n) - (n.Plans ?? []).reduce((sum, c) => sum + totalTime(c), 0));
+
+/** Kort, lesbar beskrivelse som «Index Scan using titles_rating_idx». */
+export function describeNode(node: PlanNode): string {
+ const type = node['Node Type'];
+ if (type === 'Bitmap Heap Scan') {
+ // Selve nytten av en bitmapskanning er hvilke indekser den kombinerer.
+ const indexes = [...walk(node)]
+ .map((n) => n['Index Name'])
+ .filter((name): name is string => Boolean(name));
+ const on = node['Relation Name'] ? ` on ${node['Relation Name']}` : '';
+ return `${type}${on}${indexes.length ? ` (${[...new Set(indexes)].join(', ')})` : ''}`;
+ }
+ if (node['Index Name']) return `${type} using ${node['Index Name']}`;
+ if (type === 'Sort' && node['Sort Method']) return `${type} (${node['Sort Method']})`;
+ if (node['Relation Name']) return `${type} on ${node['Relation Name']}`;
+ return type;
+}
+
+export interface ParsedPlan {
+ executionMs: number;
+ mainNode: string;
+}
+
+/** Leser `EXPLAIN (ANALYZE, FORMAT JSON)`-svaret: [{ Plan, "Execution Time", ... }]. */
+export function parsePlan(explainJson: unknown): ParsedPlan {
+ const root = Array.isArray(explainJson) ? explainJson[0] : undefined;
+ const plan = root?.Plan as PlanNode | undefined;
+ const executionMs = root?.['Execution Time'];
+ if (!plan || typeof executionMs !== 'number') {
+ throw new Error('Uventet svar fra EXPLAIN: fant ikke Plan og Execution Time.');
+ }
+ let main = plan;
+ for (const node of walk(plan)) if (selfTime(node) > selfTime(main)) main = node;
+ return { executionMs, mainNode: describeNode(main) };
+}
+
+export function median(values: number[]): number {
+ if (values.length === 0) throw new Error('Median av ingen verdier.');
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
+}
+
+const READ_ONLY_SQL = /^\s*(SELECT|WITH)\b/i;
+
+/**
+ * Kjører EXPLAIN ANALYZE på én setning. ANALYZE kjører spørringen på ekte, så alt som ikke er en ren
+ * SELECT avvises her i tillegg til at økten er satt til skrivebeskyttet.
+ */
+export async function explainStatement(db: Pool, stmt: Statement): Promise {
+ if (!READ_ONLY_SQL.test(stmt.text)) {
+ throw new Error('Bare SELECT kan måles (EXPLAIN ANALYZE kjører setningen).');
+ }
+ const { rows } = await db.query<{ 'QUERY PLAN': unknown }>(
+ `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${stmt.text}`,
+ stmt.values,
+ );
+ return parsePlan(rows[0]?.['QUERY PLAN']);
+}
+
+export const NO_FILTERS: Filters = {
+ genres: [],
+ decades: [],
+ types: [],
+ minRating: null,
+ availableOnly: false,
+};
+
+const PAGE_SIZE = 20;
+const DEEP_PAGE = 50;
+
+interface Captured {
+ statements: Statement[];
+ /** Vises i parentes bak scenarionavnet, f.eks. hvis datasettet var for lite til å nå side 50. */
+ note?: string;
+}
+
+export interface Scenario {
+ name: string;
+ /** `db` kan brukes til å kjøre ekte spørringer (cursor-paginering); capture-poolen til å fange SQL. */
+ capture: (db: Pool) => Promise;
+}
+
+const search = (
+ name: string,
+ query: string | null,
+ field: SortField,
+ filters: Filters = NO_FILTERS,
+): Scenario => ({
+ name,
+ capture: async () => {
+ const { pool, captured } = capturePool();
+ const args: SearchArgs = {
+ query,
+ filters,
+ field,
+ direction: 'DESC',
+ first: PAGE_SIZE,
+ after: null,
+ };
+ await searchTitles(pool, args);
+ return { statements: captured };
+ },
+});
+
+export const SCENARIOS: Scenario[] = [
+ search('Søk «dark», relevans', 'dark', 'RELEVANCE'),
+ search('Søk «dark», rating', 'dark', 'RATING'),
+ search('Søk «a» (1 tegn, ordprefiks), relevans', 'a', 'RELEVANCE'),
+ search('Søk «ma» (2 tegn, ordprefiks), relevans', 'ma', 'RELEVANCE'),
+ search('Søk «the» (vanlig ord), relevans', 'the', 'RELEVANCE'),
+ search('Søk «amelie» (aksent), relevans', 'amelie', 'RELEVANCE'),
+ search('Ingen søk, rating, sjanger Drama', null, 'RATING', { ...NO_FILTERS, genres: ['Drama'] }),
+ {
+ name: `Side ${DEEP_PAGE} via cursor, rating`,
+ capture: async (db) => {
+ // Cursoren kan bare lages av en faktisk forrige side, så de første sidene kjøres på ekte.
+ let after: string | null = null;
+ let page = 1;
+ while (page < DEEP_PAGE) {
+ const result: Awaited> = await searchTitles(db, {
+ query: null,
+ filters: NO_FILTERS,
+ field: 'RATING',
+ direction: 'DESC',
+ first: PAGE_SIZE,
+ after,
+ });
+ if (!result.pageInfo.hasNextPage || !result.pageInfo.endCursor) break;
+ after = result.pageInfo.endCursor;
+ page++;
+ }
+ const { pool, captured } = capturePool();
+ await searchTitles(pool, {
+ query: null,
+ filters: NO_FILTERS,
+ field: 'RATING',
+ direction: 'DESC',
+ first: PAGE_SIZE,
+ after,
+ });
+ return {
+ statements: captured,
+ note: page < DEEP_PAGE ? `datasettet rakk bare til side ${page}` : undefined,
+ };
+ },
+ },
+ {
+ name: 'totalCount for «the»',
+ capture: async () => {
+ const { pool, captured } = capturePool();
+ const connection = await searchTitles(pool, {
+ query: 'the',
+ filters: NO_FILTERS,
+ field: 'RELEVANCE',
+ direction: 'DESC',
+ first: PAGE_SIZE,
+ after: null,
+ });
+ // totalCount er lazy: telle-spørringen bygges først nå, akkurat som når klienten ber om feltet.
+ captured.length = 0;
+ await connection.totalCount();
+ return { statements: captured };
+ },
+ },
+ {
+ // API-et holder disse i minnet i fem minutter (facetCache.ts). Her måles det en bom koster:
+ // det første kallet etter oppstart eller utløp, og det alle kall kostet før cachen.
+ name: 'Fasetter uten filter (uten cache)',
+ capture: async () => {
+ const { pool, captured } = capturePool();
+ await getFacets(pool, null, NO_FILTERS);
+ return { statements: captured };
+ },
+ },
+];
+
+export interface ScenarioResult {
+ name: string;
+ /** Median av summen av alle setningene i scenarioet, per kjøring. */
+ ms: number;
+ mainNode: string;
+ statementCount: number;
+}
+
+export const RUNS = 5;
+
+export interface MeasureOptions {
+ runs?: number;
+ warmups?: number;
+ onProgress?: (message: string) => void;
+}
+
+export async function measure(
+ db: Pool,
+ scenarios: Scenario[] = SCENARIOS,
+ { runs = RUNS, warmups = 1, onProgress = () => {} }: MeasureOptions = {},
+): Promise {
+ const results: ScenarioResult[] = [];
+ for (const scenario of scenarios) {
+ onProgress(scenario.name);
+ const { statements, note } = await scenario.capture(db);
+ // Oppvarming slik at tallene gjelder varm cache (som i drift), ikke første lesing fra disk.
+ for (let i = 0; i < warmups; i++) {
+ for (const stmt of statements) await explainStatement(db, stmt);
+ }
+ const sums: number[] = [];
+ const perStatement: number[][] = statements.map(() => []);
+ let lastPlans: ParsedPlan[] = [];
+ for (let run = 0; run < runs; run++) {
+ lastPlans = [];
+ for (const [i, stmt] of statements.entries()) {
+ const plan = await explainStatement(db, stmt);
+ lastPlans.push(plan);
+ perStatement[i]!.push(plan.executionMs);
+ }
+ sums.push(lastPlans.reduce((sum, p) => sum + p.executionMs, 0));
+ }
+ // Fasettene sender fire spørringer; planen som vises er den tregeste av dem.
+ const slowest = perStatement
+ .map((ms) => median(ms))
+ .reduce((best, v, i, all) => (v > all[best]! ? i : best), 0);
+ results.push({
+ name: note ? `${scenario.name} (${note})` : scenario.name,
+ ms: median(sums),
+ mainNode: lastPlans[slowest]?.mainNode ?? '(ingen spørring)',
+ statementCount: statements.length,
+ });
+ }
+ return results;
+}
+
+export interface ReportInfo {
+ titleCount: number;
+ pgVersion: string;
+ date: string;
+ runs: number;
+ jit: string;
+}
+
+const cell = (text: string) => text.replaceAll('|', '\\|');
+const formatMs = (ms: number) => ms.toFixed(ms < 10 ? 2 : 1).replace('.', ',');
+
+export function formatReport(info: ReportInfo, results: ScenarioResult[]): string {
+ const rows = results.map((r) => {
+ const name = r.statementCount > 1 ? `${r.name}, sum av ${r.statementCount} spørringer` : r.name;
+ return `| ${cell(name)} | ${formatMs(r.ms)} | ${cell(r.mainNode)} |`;
+ });
+ return [
+ `**Målt ${info.date}**: ${info.titleCount.toLocaleString('nb-NO')} titler, PostgreSQL ${info.pgVersion}, jit = ${info.jit}.`,
+ `Median av ${info.runs} kjøringer med \`EXPLAIN (ANALYZE, BUFFERS)\` etter én oppvarming (varm cache). Søkene har \`LIMIT 21\` (= \`first: 20\` + 1).`,
+ '',
+ '| Scenario | ms (median) | Planens hovednode |',
+ '| --- | ---: | --- |',
+ ...rows,
+ '',
+ ].join('\n');
+}
+
+async function main() {
+ // Rolig timeout: en kald, uindeksert spørring mot hele datasettet kan ta sekunder, og en
+ // avbrutt måling sier ingenting. Poolens vanlige 15 s er til for å beskytte API-et.
+ const pool = createPool(config.databaseUrl, { statementTimeoutMs: 120_000 });
+ const client = await pool.connect();
+ try {
+ // Målingen skal aldri kunne endre data, selv om en spørring skulle være feil bygget.
+ await client.query('SET default_transaction_read_only = on');
+ const db = { query: client.query.bind(client) } as unknown as Pool;
+ const count = await client.query<{ n: string }>('SELECT count(*) AS n FROM titles');
+ const version = await client.query<{ server_version: string }>('SHOW server_version');
+ const jit = await client.query<{ jit: string }>('SHOW jit');
+ const results = await measure(db, SCENARIOS, {
+ onProgress: (name) => console.error(`Måler: ${name}`),
+ });
+ process.stdout.write(
+ formatReport(
+ {
+ titleCount: Number(count.rows[0]?.n ?? 0),
+ pgVersion: version.rows[0]?.server_version.split(' ')[0] ?? 'ukjent',
+ date: new Date().toISOString().slice(0, 10),
+ runs: RUNS,
+ jit: jit.rows[0]?.jit ?? 'ukjent',
+ },
+ results,
+ ),
+ );
+ } finally {
+ client.release();
+ await pool.end();
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((err) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+ });
+}
diff --git a/backend/scripts/migrate.test.ts b/backend/scripts/migrate.test.ts
new file mode 100644
index 0000000..f17f0f6
--- /dev/null
+++ b/backend/scripts/migrate.test.ts
@@ -0,0 +1,61 @@
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import pg from 'pg';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { config } from '../src/config.js';
+import { migrate } from './migrate.js';
+
+// Eget skjema så schema_migrations og prøvetabellen ikke blander seg med testdatabasens egne.
+const SCHEMA = 'migrate_test';
+
+let admin: pg.Pool;
+let pool: pg.Pool;
+let dir: string;
+
+beforeAll(async () => {
+ admin = new pg.Pool({ connectionString: config.testDatabaseUrl, max: 1 });
+ await admin.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`);
+ await admin.query(`CREATE SCHEMA ${SCHEMA}`);
+ // Samme type grense som createPool setter i API-et, men kort nok til at testen går raskt.
+ pool = new pg.Pool({
+ connectionString: config.testDatabaseUrl,
+ max: 1,
+ statement_timeout: 100,
+ options: `-c search_path=${SCHEMA}`,
+ });
+ dir = mkdtempSync(path.join(tmpdir(), 'migrate-test-'));
+ writeFileSync(
+ path.join(dir, '001_slow.sql'),
+ // Fire ganger poolens statement_timeout: ville blitt avbrutt uten SET i migrate().
+ `SELECT pg_sleep(0.4);
+ CREATE TABLE probe AS
+ SELECT current_setting('statement_timeout') AS st, current_setting('lock_timeout') AS lt;`,
+ );
+});
+
+afterAll(async () => {
+ await pool.end();
+ await admin.query(`DROP SCHEMA ${SCHEMA} CASCADE`);
+ await admin.end();
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe('migrate', () => {
+ it('avbryter ikke en langsom setning og setter lock_timeout under migreringen', async () => {
+ await expect(migrate(pool, () => {}, dir)).resolves.toEqual(['001_slow.sql']);
+ const { rows } = await pool.query<{ st: string; lt: string }>('SELECT st, lt FROM probe');
+ expect(rows[0]).toEqual({ st: '0', lt: '30s' });
+ });
+
+ it('gjenoppretter poolens grenser etterpå (tilkoblingen er delt med resten av prosessen)', async () => {
+ const { rows } = await pool.query<{ st: string; lt: string }>(
+ "SELECT current_setting('statement_timeout') AS st, current_setting('lock_timeout') AS lt",
+ );
+ expect(rows[0]).toEqual({ st: '100ms', lt: '0' });
+ });
+
+ it('er idempotent: kjører ingenting når alt er kjørt', async () => {
+ await expect(migrate(pool, () => {}, dir)).resolves.toEqual([]);
+ });
+});
diff --git a/backend/scripts/migrate.ts b/backend/scripts/migrate.ts
index 1299fc5..41c7e02 100644
--- a/backend/scripts/migrate.ts
+++ b/backend/scripts/migrate.ts
@@ -30,8 +30,8 @@ const LOCK_KEY = 281_001;
export async function migrate(
pool: Pool,
log: (msg: string) => void = () => {},
+ dir: string = findMigrationsDir(),
): Promise {
- const dir = findMigrationsDir();
const files = readdirSync(dir)
.filter((f) => /^\d+_.+\.sql$/.test(f))
.sort();
@@ -40,6 +40,12 @@ export async function migrate(
const client = await pool.connect();
const applied: string[] = [];
try {
+ // Poolens statement_timeout (15 s) er et vern mot løpske spørringer fra API-et, ikke mot
+ // migreringer: ALTER TABLE ... ADD COLUMN ... STORED skriver om hele titles og tar 10-20 s
+ // eller mer på VM-en med 190 000 titler. En avbrutt migrering ruller tilbake og kan aldri
+ // fullføres. lock_timeout i stedet for å vente evig på en lås som en hengende økt holder.
+ await client.query('SET statement_timeout = 0');
+ await client.query("SET lock_timeout = '30s'");
await client.query('SELECT pg_advisory_lock($1)', [LOCK_KEY]);
await client.query(`CREATE TABLE IF NOT EXISTS schema_migrations (
name text PRIMARY KEY,
@@ -67,6 +73,9 @@ export async function migrate(
}
} finally {
await client.query('SELECT pg_advisory_unlock($1)', [LOCK_KEY]).catch(() => {});
+ // Tilkoblingen går tilbake til poolen; uten RESET ville resten av prosessen (f.eks. importen)
+ // fortsatt kjørt uten statement_timeout.
+ await client.query('RESET statement_timeout; RESET lock_timeout').catch(() => {});
client.release();
}
return applied;
diff --git a/backend/scripts/setup-core.ts b/backend/scripts/setup-core.ts
new file mode 100644
index 0000000..c85ffd9
--- /dev/null
+++ b/backend/scripts/setup-core.ts
@@ -0,0 +1,152 @@
+import { copyFileSync, existsSync } from 'node:fs';
+import path from 'node:path';
+import pg from 'pg';
+
+/**
+ * Rene hjelpefunksjoner for `npm run setup`. De ligger adskilt fra setup.ts fordi setup.ts må opprette
+ * .env FØR `src/config.ts` leses (config laster .env ved import), og fordi disse kan testes uten
+ * en Postgres-server.
+ */
+
+/** Antall syntetiske titler som genereres når ingenting annet er bedt om: raskt, men nok til å se søk og paginering. */
+export const DEFAULT_FIXTURE_SIZE = 20_000;
+
+/**
+ * Kopierer .env.example til .env hvis .env mangler. Overskriver aldri: en eksisterende .env kan
+ * inneholde passord og en ekte TMDB-nøkkel som ikke kommer tilbake.
+ */
+export function ensureEnvFile(root: string): 'created' | 'exists' | 'no-template' {
+ const target = path.join(root, '.env');
+ if (existsSync(target)) return 'exists';
+ const template = path.join(root, '.env.example');
+ if (!existsSync(template)) return 'no-template';
+ copyFileSync(template, target);
+ return 'created';
+}
+
+/** FIXTURE_SIZE fra miljøet, ellers standard. Samme regel som db:fixture, men med en tydelig feilmelding. */
+export function fixtureSize(env: NodeJS.ProcessEnv): number {
+ const raw = env.FIXTURE_SIZE?.trim();
+ if (!raw) return DEFAULT_FIXTURE_SIZE;
+ const n = Number(raw);
+ if (!Number.isInteger(n) || n < 20) {
+ throw new Error(`FIXTURE_SIZE må være et heltall ≥ 20, fikk «${raw}».`);
+ }
+ return n;
+}
+
+/** Passordet skal aldri havne i terminalen eller i en logg som limes inn i en chat. */
+export function redact(connectionString: string): string {
+ try {
+ const url = new URL(connectionString);
+ if (url.password) url.password = '***';
+ return url.toString();
+ } catch {
+ return '(ugyldig tilkoblingsstreng)';
+ }
+}
+
+/** Databasenavnet i en tilkoblingsstreng. */
+export function databaseName(connectionString: string): string {
+ const name = decodeURIComponent(new URL(connectionString).pathname.slice(1));
+ if (!name)
+ throw new Error(`Tilkoblingsstrengen mangler databasenavn: ${redact(connectionString)}`);
+ return name;
+}
+
+/** Samme server, men koblet til vedlikeholdsdatabasen `postgres` der CREATE DATABASE kan kjøres. */
+export function adminConnectionString(connectionString: string): string {
+ const url = new URL(connectionString);
+ url.pathname = '/postgres';
+ return url.toString();
+}
+
+export function quoteIdent(name: string): string {
+ return `"${name.replace(/"/g, '""')}"`;
+}
+
+/**
+ * Gjør en rå feil fra pg/Node om til noe en student kan handle på. Postgres-feilkoder (SQLSTATE) og
+ * Node-nettverkskoder er stabile, i motsetning til meldingsteksten.
+ */
+export function explainConnectionError(err: unknown, connectionString: string): string {
+ const e = err as { code?: string; message?: string };
+ const shown = redact(connectionString);
+ // AggregateError (localhost som både IPv4 og IPv6) har tom melding.
+ const raw = e.message || 'ingen svar';
+ // Når serveren krever passord og URL-en ikke har noe, kaster pg en vanlig Error uten SQLSTATE.
+ const code = e.code ?? (/password/i.test(raw) ? '28P01' : undefined);
+ switch (code) {
+ case 'ECONNREFUSED':
+ case 'ETIMEDOUT':
+ case 'ENOTFOUND':
+ return (
+ `Fikk ikke kontakt med PostgreSQL (${shown}).\n` +
+ ' - Er PostgreSQL startet? Linux: `sudo systemctl start postgresql`, macOS (Homebrew): `brew services start postgresql`.\n' +
+ ' - Bruker serveren en annen vert eller port? Rediger DATABASE_URL og TEST_DATABASE_URL i .env.'
+ );
+ case '28P01':
+ case '28000':
+ return (
+ `PostgreSQL avviste innloggingen (${shown}): ${raw}\n` +
+ ' Sett riktig bruker og passord i .env, for eksempel:\n' +
+ ' DATABASE_URL=postgres://BRUKER:PASSORD@localhost:5432/project2\n' +
+ ' TEST_DATABASE_URL=postgres://BRUKER:PASSORD@localhost:5432/project2_test\n' +
+ ' Mangler du en bruker: sudo -u postgres psql -c "CREATE USER it2810 PASSWORD \'hemmelig\' SUPERUSER CREATEDB;"'
+ );
+ case '42501':
+ return (
+ `Brukeren i ${shown} har ikke lov til å opprette databaser (${raw}).\n` +
+ ' Gi den CREATEDB (og SUPERUSER, siden migreringene lager utvidelsene pg_trgm og unaccent):\n' +
+ ' sudo -u postgres psql -c "ALTER USER BRUKER WITH CREATEDB SUPERUSER;"\n' +
+ ' eller opprett databasene selv: `sudo -u postgres createdb project2` og `sudo -u postgres createdb project2_test`.'
+ );
+ case '3D000':
+ return (
+ `Databasen «postgres» finnes ikke på serveren (${shown}), så setup får ikke opprettet de andre.\n` +
+ ' Opprett databasene selv (`createdb project2`, `createdb project2_test`) og kjør `npm run setup` på nytt.'
+ );
+ default:
+ return `Kunne ikke koble til PostgreSQL (${shown}): ${raw}`;
+ }
+}
+
+/** Hint som skrives når migreringen feiler; den vanligste årsaken er manglende utvidelser eller rettigheter. */
+export const MIGRATION_HINT =
+ 'Migreringen feilet. Vanlige årsaker:\n' +
+ ' - `permission denied to create extension "pg_trgm"` eller "unaccent": brukeren må være superuser, eller en superuser\n' +
+ ' må kjøre `CREATE EXTENSION pg_trgm; CREATE EXTENSION unaccent;` i databasen først.\n' +
+ ' - `extension "pg_trgm" is not available`: installer contrib-pakken (Debian/Ubuntu: `sudo apt install postgresql-contrib`).';
+
+/**
+ * Oppretter databasen hvis den mangler. Kobler til `postgres` på samme server (som e2e/global-setup.ts),
+ * fordi man ikke kan kjøre CREATE DATABASE fra en database som ikke finnes ennå.
+ */
+export async function ensureDatabase(connectionString: string): Promise<'created' | 'exists'> {
+ const name = databaseName(connectionString);
+ const admin = new pg.Client({
+ connectionString: adminConnectionString(connectionString),
+ connectionTimeoutMillis: 5000,
+ });
+ // pg emitterer 'error' på klienten ved frakoblet server midt i en spørring; uten lytter krasjer prosessen.
+ admin.on('error', () => {});
+ try {
+ await admin.connect();
+ } catch (err) {
+ throw new Error(explainConnectionError(err, connectionString), { cause: err });
+ }
+ try {
+ const found = await admin.query('SELECT 1 FROM pg_database WHERE datname = $1', [name]);
+ if (found.rowCount) return 'exists';
+ try {
+ await admin.query(`CREATE DATABASE ${quoteIdent(name)}`);
+ } catch (err) {
+ // 42P04 = duplicate_database: en annen prosess rakk det først, som er like bra.
+ if ((err as { code?: string }).code === '42P04') return 'exists';
+ throw new Error(explainConnectionError(err, connectionString), { cause: err });
+ }
+ return 'created';
+ } finally {
+ await admin.end();
+ }
+}
diff --git a/backend/scripts/setup.test.ts b/backend/scripts/setup.test.ts
new file mode 100644
index 0000000..fc030c7
--- /dev/null
+++ b/backend/scripts/setup.test.ts
@@ -0,0 +1,141 @@
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import pg from 'pg';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { config } from '../src/config.js';
+import {
+ adminConnectionString,
+ databaseName,
+ DEFAULT_FIXTURE_SIZE,
+ ensureDatabase,
+ ensureEnvFile,
+ explainConnectionError,
+ fixtureSize,
+ quoteIdent,
+ redact,
+} from './setup-core.js';
+
+describe('ensureEnvFile', () => {
+ let dir: string;
+ beforeEach(() => {
+ dir = mkdtempSync(path.join(tmpdir(), 'setup-env-'));
+ });
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
+
+ it('copies .env.example to .env when .env is missing', () => {
+ writeFileSync(path.join(dir, '.env.example'), 'PORT=3001\n');
+ expect(ensureEnvFile(dir)).toBe('created');
+ expect(readFileSync(path.join(dir, '.env'), 'utf8')).toBe('PORT=3001\n');
+ });
+
+ it('never overwrites an existing .env', () => {
+ writeFileSync(path.join(dir, '.env.example'), 'PORT=3001\n');
+ writeFileSync(path.join(dir, '.env'), 'TMDB_API_KEY=secret\n');
+ expect(ensureEnvFile(dir)).toBe('exists');
+ expect(readFileSync(path.join(dir, '.env'), 'utf8')).toBe('TMDB_API_KEY=secret\n');
+ });
+
+ it('does not crash when there is no template', () => {
+ expect(ensureEnvFile(dir)).toBe('no-template');
+ });
+
+ it('keeps an empty existing .env as it is', () => {
+ writeFileSync(path.join(dir, '.env.example'), 'PORT=3001\n');
+ writeFileSync(path.join(dir, '.env'), '');
+ expect(ensureEnvFile(dir)).toBe('exists');
+ expect(readFileSync(path.join(dir, '.env'), 'utf8')).toBe('');
+ });
+});
+
+describe('fixtureSize', () => {
+ it('defaults to a fast dataset', () => {
+ expect(fixtureSize({})).toBe(DEFAULT_FIXTURE_SIZE);
+ expect(fixtureSize({ FIXTURE_SIZE: '' })).toBe(DEFAULT_FIXTURE_SIZE);
+ });
+
+ it('accepts the large dataset', () => {
+ expect(fixtureSize({ FIXTURE_SIZE: '120000' })).toBe(120_000);
+ });
+
+ it.each(['abc', '12.5', '5', '-1'])('rejects %s', (v) => {
+ expect(() => fixtureSize({ FIXTURE_SIZE: v })).toThrow(/FIXTURE_SIZE/);
+ });
+});
+
+describe('connection string helpers', () => {
+ it('extracts the database name and builds the admin URL on the same server', () => {
+ const url = 'postgres://u:pw@db.example:5433/project2?sslmode=disable';
+ expect(databaseName(url)).toBe('project2');
+ const admin = new URL(adminConnectionString(url));
+ expect(admin.pathname).toBe('/postgres');
+ expect(admin.host).toBe('db.example:5433');
+ expect(admin.username).toBe('u');
+ });
+
+ it('rejects a URL without a database name', () => {
+ expect(() => databaseName('postgres://u@localhost:5432')).toThrow(/databasenavn/);
+ });
+
+ it('hides passwords', () => {
+ expect(redact('postgres://u:hemmelig@localhost/x')).not.toContain('hemmelig');
+ expect(redact('not a url')).toBe('(ugyldig tilkoblingsstreng)');
+ });
+
+ it('quotes identifiers', () => {
+ expect(quoteIdent('a"b')).toBe('"a""b"');
+ });
+});
+
+describe('explainConnectionError', () => {
+ const url = 'postgres://postgres:pw@localhost:5432/project2';
+
+ it('tells the user to start PostgreSQL when nothing answers', () => {
+ const msg = explainConnectionError(Object.assign(new Error(''), { code: 'ECONNREFUSED' }), url);
+ expect(msg).toMatch(/Er PostgreSQL startet/);
+ expect(msg).not.toContain(':pw@');
+ });
+
+ it('shows an example DATABASE_URL with password on authentication failure', () => {
+ const msg = explainConnectionError(Object.assign(new Error('nope'), { code: '28P01' }), url);
+ expect(msg).toContain('DATABASE_URL=postgres://BRUKER:PASSORD@localhost:5432/project2');
+ });
+
+ it("recognises pg's missing-password error, which has no error code", () => {
+ const err = new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string');
+ expect(explainConnectionError(err, url)).toContain('BRUKER:PASSORD');
+ });
+
+ it('explains CREATEDB on insufficient privilege', () => {
+ const msg = explainConnectionError(Object.assign(new Error('x'), { code: '42501' }), url);
+ expect(msg).toMatch(/CREATEDB/);
+ });
+
+ it('falls back to the raw message', () => {
+ expect(explainConnectionError(new Error('boom'), url)).toContain('boom');
+ });
+});
+
+describe('ensureDatabase (against the test server)', () => {
+ const name = `project2_setup_unit_${process.pid}`;
+ const url = Object.assign(new URL(config.testDatabaseUrl), { pathname: `/${name}` }).toString();
+
+ afterEach(async () => {
+ const admin = new pg.Client({ connectionString: adminConnectionString(url) });
+ await admin.connect();
+ await admin.query(`DROP DATABASE IF EXISTS ${quoteIdent(name)}`);
+ await admin.end();
+ });
+
+ it('creates a missing database and is idempotent', async () => {
+ expect(await ensureDatabase(url)).toBe('created');
+ expect(await ensureDatabase(url)).toBe('exists');
+ });
+
+ it('explains an unreachable server without leaking the password', async () => {
+ const dead = 'postgres://u:hemmelig@127.0.0.1:1/project2';
+ const err = await ensureDatabase(dead).catch((e: Error) => e);
+ expect((err as Error).message).toMatch(/Fikk ikke kontakt med PostgreSQL/);
+ expect((err as Error).message).not.toContain('hemmelig');
+ });
+});
diff --git a/backend/scripts/setup.ts b/backend/scripts/setup.ts
new file mode 100644
index 0000000..388aef3
--- /dev/null
+++ b/backend/scripts/setup.ts
@@ -0,0 +1,121 @@
+import { spawn } from 'node:child_process';
+import { existsSync } from 'node:fs';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import {
+ databaseName,
+ ensureDatabase,
+ ensureEnvFile,
+ fixtureSize,
+ MIGRATION_HINT,
+ redact,
+} from './setup-core.js';
+
+/**
+ * `npm run setup`: gjør en fersk klon om til noe som kan kjøres med `npm run dev`, uten nøkler,
+ * VPN eller nedlasting. Hvert steg kan kjøres på nytt uten skade (.env overskrives aldri, databaser
+ * opprettes bare hvis de mangler, migrering og import er upserts).
+ */
+
+// Ikke import fra ../src/config.js på toppnivå: config leser .env ved import, og .env finnes kanskje
+// ikke før steg 1 har kjørt.
+const repoRoot = path.resolve(import.meta.dirname, '..', '..');
+
+function step(n: number, total: number, msg: string) {
+ console.log(`\n[${n}/${total}] ${msg}`);
+}
+
+/**
+ * Kjører en npm-kommando i terminalen. `shell: true` gjør at `npm` (npm.cmd på Windows) finnes uten
+ * plattformspesifikk kode.
+ */
+function run(command: string, env: NodeJS.ProcessEnv = {}): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, {
+ cwd: repoRoot,
+ env: { ...process.env, ...env },
+ stdio: 'inherit',
+ shell: true,
+ });
+ child.once('error', reject);
+ child.once('exit', (code, signal) =>
+ code === 0 ? resolve() : reject(new Error(`«${command}» avsluttet med ${code ?? signal}.`)),
+ );
+ });
+}
+
+const TOTAL = 6;
+
+async function main() {
+ step(1, TOTAL, 'Miljøfil');
+ const envState = ensureEnvFile(repoRoot);
+ if (envState === 'created') console.log('Lagde .env fra .env.example.');
+ else if (envState === 'exists') console.log('.env finnes allerede og beholdes som den er.');
+ else console.log('Fant verken .env eller .env.example; bruker standardverdier.');
+
+ const { config } = await import('../src/config.js');
+ if (config.databaseUrl === config.testDatabaseUrl) {
+ throw new Error(
+ 'DATABASE_URL og TEST_DATABASE_URL må være to ulike databaser (testene tømmer den ene).',
+ );
+ }
+
+ step(2, TOTAL, 'Databaser');
+ for (const [label, url] of [
+ ['app', config.databaseUrl],
+ ['test', config.testDatabaseUrl],
+ ] as const) {
+ const state = await ensureDatabase(url);
+ console.log(
+ `${state === 'created' ? 'Opprettet' : 'Fant'} ${label}databasen «${databaseName(url)}» (${redact(url)}).`,
+ );
+ }
+
+ step(3, TOTAL, 'Skjema (migrering)');
+ try {
+ await run('npm run db:migrate -w backend');
+ } catch (err) {
+ throw new Error(`${(err as Error).message}\n\n${MIGRATION_HINT}`, { cause: err });
+ }
+
+ step(4, TOTAL, 'Titler');
+ const basics = path.join(config.imdbDataDir, 'title.basics.tsv.gz');
+ const ratings = path.join(config.imdbDataDir, 'title.ratings.tsv.gz');
+ if (existsSync(basics) && existsSync(ratings)) {
+ console.log(`Bruker datafilene som allerede ligger i ${config.imdbDataDir}.`);
+ } else {
+ const size = fixtureSize(process.env);
+ console.log(
+ `Ingen datafiler i ${config.imdbDataDir}; genererer ${size} syntetiske titler ` +
+ '(FIXTURE_SIZE=120000 gir det store settet).',
+ );
+ await run('npm run db:fixture -w backend', { FIXTURE_SIZE: String(size) });
+ }
+ await run('npm run db:seed -w backend');
+
+ step(5, TOTAL, 'Gratisfilmer (falsk Internet Archive)');
+ // Tilfeldig ledig port: setup skal ikke feile om 3998 er opptatt av en kjørende dev- eller E2E-stack.
+ // Databasen lagrer bare pekere (element-id og filnavn), ikke vertsnavnet, så porten spiller ingen rolle senere.
+ const { startArchiveMock } = await import('./archive-mock.js');
+ const mock = await startArchiveMock(0);
+ try {
+ await run('npm run db:archive -w backend', { ARCHIVE_URL: `http://localhost:${mock.port}` });
+ } finally {
+ await mock.close();
+ }
+
+ step(6, TOTAL, 'Kontroll');
+ console.log(
+ config.tmdbApiKey
+ ? 'TMDB_API_KEY er satt i .env; plakater hentes fra ekte TMDB.'
+ : 'TMDB_API_KEY er tom; `npm run dev` bruker en falsk TMDB-server med tegnede plakater.',
+ );
+ console.log('\nFerdig! Kjør npm run dev og åpne http://localhost:5173/project2/');
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((err) => {
+ console.error(`\nSetup feilet:\n${err instanceof Error ? err.message : err}`);
+ process.exit(1);
+ });
+}
diff --git a/backend/src/app.ts b/backend/src/app.ts
index c4facf2..a0899f8 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -1,13 +1,40 @@
-import { GraphQLError, type ValidationRule } from 'graphql';
+import { GraphQLError, parse, type ValidationRule } from 'graphql';
import { createYoga, type Plugin } from 'graphql-yoga';
import { ArtworkService } from './artwork.js';
import { config } from './config.js';
import { createContext, type Context } from './context.js';
import type { Pool } from './db.js';
+import { complexityLimit } from './complexityLimit.js';
+import { isDatabaseUnavailable, serviceUnavailableError } from './dbErrors.js';
import { depthLimit } from './depthLimit.js';
+import { FacetCache } from './facetCache.js';
+import { DEFAULT_RATE_LIMITS, MutationLimiter, type RateLimitOptions } from './rateLimit.js';
import { schema } from './schema.js';
+import type { FacetsResult } from './search.js';
+
+/** Serverkonteksten Yoga får fra node:http; fravær av `req` betyr kall via yoga.fetch (tester). */
+interface NodeServerContext {
+ req?: { socket?: { remoteAddress?: string } };
+}
export const MAX_QUERY_DEPTH = 6;
+// Frontendens største spørring (Title-detaljene) har ca. 45 felt og ett rotfelt. Grensene gir rundt
+// tre ganger slingringsmonn, men stopper hundrevis av aliasede søk eller TMDB-oppslag i ett dokument.
+export const MAX_ROOT_FIELDS = 8;
+export const MAX_FIELDS = 150;
+// Vektet kostnad (felt under en liste teller `first` ganger, variabel `first` = 50). Frontendens
+// største spørring (forsiden/Featured) ligger på ca. 1 100; 2 500 slipper den gjennom to ganger,
+// men stopper f.eks. 8 x search(first: 50) med nøstede anmeldelser.
+export const MAX_COST = 2500;
+// Tak på antall tokens i ett dokument. graphql-js sin regel OverlappingFieldsCanBeMerged er
+// kvadratisk i antall felt, og complexityLimit stopper ikke de andre reglene: `{ genres genres ... }`
+// med 8 000 felt (55 kB) holdt event-loopen i ca. 5 s. Tokengrensen avviser dokumentet under
+// parsing, før noen valideringsregel kjører. Frontendens største operasjon har 128 tokens og
+// introspeksjonsspørringen til GraphiQL ca. 180, så 1 000 gir åtte ganger slingringsmonn.
+export const MAX_TOKENS = 1000;
+// Alle legitime forespørsler er små (spørring + variabler); 25 MB-standarden i Yoga lar en
+// angriper tvinge oss til å lese og JSON-parse store kropper før noen grense slår inn.
+export const MAX_REQUEST_BODY_BYTES = 64 * 1024;
/**
* Egen regel i stedet for graphqls NoSchemaIntrospectionCustomRule: den bruker instanceof-sjekker
@@ -26,7 +53,29 @@ const noIntrospection: ValidationRule = (context) => ({
},
});
-export interface AppOptions {
+const HEALTH_TIMEOUT_MS = 2000;
+
+/**
+ * Kort tidsgrense utenom poolens egen: en database bak en brannmur svarer ikke i det hele tatt,
+ * og helsesjekken skal da svare 503 raskt i stedet for å henge til Apache gir opp.
+ */
+async function databaseResponds(pool: Pool, timeoutMs = HEALTH_TIMEOUT_MS): Promise {
+ let timer: NodeJS.Timeout | undefined;
+ const timeout = new Promise((resolve) => {
+ timer = setTimeout(() => resolve(false), timeoutMs);
+ });
+ const query = pool.query('SELECT 1').then(
+ () => true,
+ () => false,
+ );
+ try {
+ return await Promise.race([query, timeout]);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+interface AppOptions {
pool: Pool;
/** Slår av introspeksjon og GraphiQL. Standard: NODE_ENV === 'production'. */
production?: boolean;
@@ -36,6 +85,10 @@ export interface AppOptions {
artwork?: ArtworkService;
/** Base-URL for Internet Archive i stream-URL-ene. Standard: ARCHIVE_URL. */
archiveUrl?: string;
+ /** Overstyrer grensene for mutations (se rateLimit.ts). Standard: DEFAULT_RATE_LIMITS. */
+ rateLimits?: Partial;
+ /** Cache for fasetter uten søketekst. Standard: FacetCache med fem minutters levetid. */
+ facetCache?: FacetCache;
}
/**
@@ -48,24 +101,64 @@ export function createApp({
corsOrigin = false,
artwork,
archiveUrl,
+ rateLimits,
+ facetCache = new FacetCache(),
}: AppOptions) {
+ // Én begrenser per app: tilstanden må overleve mellom requests, i motsetning til konteksten.
+ const limiter = new MutationLimiter({ ...DEFAULT_RATE_LIMITS, ...rateLimits });
const artworkService = artwork ?? new ArtworkService({ pool, apiKey: config.tmdbApiKey });
const isProd = production ?? process.env.NODE_ENV === 'production';
// Egendefinert plugin i stedet for ekstra pakker: envelop lar oss legge til valideringsregler.
const limits: Plugin = {
+ onParse({ setParseFn }) {
+ setParseFn((source, options) => parse(source, { ...options, maxTokens: MAX_TOKENS }));
+ },
onValidate({ addValidationRule }) {
addValidationRule(depthLimit(MAX_QUERY_DEPTH));
+ addValidationRule(
+ complexityLimit({
+ maxRootFields: MAX_ROOT_FIELDS,
+ maxFields: MAX_FIELDS,
+ maxCost: MAX_COST,
+ }),
+ );
if (isProd) addValidationRule(noIntrospection);
},
};
- return createYoga
}>
diff --git a/frontend/src/components/LazyRow.tsx b/frontend/src/components/LazyRow.tsx
index bbe15aa..55c28d0 100644
--- a/frontend/src/components/LazyRow.tsx
+++ b/frontend/src/components/LazyRow.tsx
@@ -1,8 +1,8 @@
import { useRef } from 'react';
import { useQuery } from '@apollo/client/react';
-import { SEARCH_QUERY } from '../graphql/operations';
+import { ROW_QUERY } from '../graphql/operations';
import { useNearViewport } from '../hooks/useNearViewport';
-import { rowVariables, seeAllSearch, type BrowseRow } from '../lib/browseRows';
+import { rowAnchorId, rowVariables, seeAllSearch, type BrowseRow } from '../lib/browseRows';
import { TitleRow } from './TitleRow';
/**
@@ -12,13 +12,14 @@ import { TitleRow } from './TitleRow';
export function LazyRow({ row }: { row: BrowseRow }) {
const anchor = useRef(null);
const near = useNearViewport(anchor);
- const { data, loading, error, refetch } = useQuery(SEARCH_QUERY, {
+ const { data, loading, error, refetch } = useQuery(ROW_QUERY, {
variables: rowVariables(row.state),
skip: !near,
});
return (
-
+ // Id og scroll-margin lar kategorinavigasjonen hoppe hit forbi den faste headeren.
+
) => void }) {
+ onClient(useApolloClient());
+ return null;
+}
+
+function setup(inMyList = false, delay = 0) {
+ const log = emptyLog();
+ const mocks = buildMocks(
+ {
+ ToggleList: (vars) => ({
+ toggleList: { __typename: 'Title', id: vars.titleId, inMyList: !inMyList },
+ }),
+ },
+ log,
+ );
+ if (delay) for (const m of mocks) m.delay = delay;
+ let client!: ReturnType;
+ renderApp(
+ <>
+ (client = c)} />
+
+ >,
+ { mocks },
+ );
+ return { log, client, user: userEvent.setup() };
+}
+
+describe('ListToggleButton', () => {
+ it('kunngjør utfallet i statusregionen', async () => {
+ const { user } = setup(false);
+ await user.click(screen.getByRole('button', { name: 'Legg i min liste' }));
+ await waitFor(() =>
+ expect(screen.getByRole('status')).toHaveTextContent('Lagt til i min liste.'),
+ );
+ });
+
+ it('beholder fokus og avviser nytt trykk mens mutasjonen pågår (aria-disabled, ikke disabled)', async () => {
+ const { user, log } = setup(false, 100);
+ const button = screen.getByRole('button', { name: 'Legg i min liste' });
+ await user.click(button);
+ expect(button).toHaveAttribute('aria-disabled', 'true');
+ expect(button).not.toBeDisabled();
+ expect(button).toHaveFocus();
+ await user.click(button);
+ await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Lagt til'));
+ expect(log.ToggleList).toHaveLength(1);
+ expect(button).not.toHaveAttribute('aria-disabled');
+ });
+
+ it('kaster «Min liste» fra cachen, så den ikke viser gammelt innhold etter endringen', async () => {
+ const { user, client } = setup(false);
+ client.writeQuery({
+ query: MY_LIST_QUERY,
+ variables: { first: 20 },
+ data: { myList: makeConnection([makeTitle(9)]) },
+ });
+ const listKeys = () => {
+ const root = (client.cache.extract() as Record).ROOT_QUERY;
+ return Object.keys(root ?? {}).filter((k) => k.startsWith('myList'));
+ };
+ expect(listKeys()).not.toEqual([]);
+
+ await user.click(screen.getByRole('button', { name: 'Legg i min liste' }));
+ await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Lagt til'));
+ expect(listKeys()).toEqual([]);
+ });
+
+ it('viser feil når mutasjonen mislykkes, og knappen er brukbar igjen', async () => {
+ // Ingen ToggleList-mock: mutasjonen feiler.
+ renderApp(, { mocks: [] });
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Legg i min liste' }));
+ expect(await screen.findByRole('alert')).toHaveTextContent('Kunne ikke oppdatere listen');
+ expect(screen.getByRole('button', { name: 'Legg i min liste' })).not.toHaveAttribute(
+ 'aria-disabled',
+ );
+ });
+});
diff --git a/frontend/src/components/ListToggleButton.tsx b/frontend/src/components/ListToggleButton.tsx
index d61aa8f..70c7045 100644
--- a/frontend/src/components/ListToggleButton.tsx
+++ b/frontend/src/components/ListToggleButton.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useMutation } from '@apollo/client/react';
import { TOGGLE_LIST_MUTATION } from '../graphql/operations';
+import { rateLimitMessage } from '../lib/apiError';
interface Props {
titleId: string;
@@ -9,20 +10,34 @@ interface Props {
variant?: 'primary' | 'secondary';
}
+/**
+ * Handlingsetikett som skifter («Legg i» / «Fjern fra min liste»), bevisst uten `aria-pressed`:
+ * en bryter med dynamisk navn leses opp motstridende («Fjern fra min liste, trykket»). En etikett som
+ * sier hva et trykk gjør er tydeligere enn en tilstand man må tolke, og utfallet kunngjøres i statusregionen.
+ */
export function ListToggleButton({ titleId, inMyList, variant = 'primary' }: Props) {
const [toggleList, { loading }] = useMutation(TOGGLE_LIST_MUTATION);
const [message, setMessage] = useState('');
- const [failed, setFailed] = useState(false);
+ const [error, setError] = useState('');
const onClick = async () => {
- setFailed(false);
+ if (loading) return;
+ setError('');
try {
// Tittelen normaliseres på id, så knappen oppdateres av cachen uten ekstra kode.
- const { data } = await toggleList({ variables: { titleId } });
+ const { data } = await toggleList({
+ variables: { titleId },
+ // «Min liste» er en egen liste i cachen som ikke vet om denne endringen. Uten å kaste den ville en
+ // tilbakenavigering vist gammelt innhold til nettverkssvaret kom (eller for alltid ved nedetid).
+ update(cache) {
+ cache.evict({ fieldName: 'myList' });
+ cache.gc();
+ },
+ });
setMessage(data?.toggleList.inMyList ? 'Lagt til i min liste.' : 'Fjernet fra min liste.');
- } catch {
+ } catch (e) {
setMessage('');
- setFailed(true);
+ setError(rateLimitMessage(e) ?? 'Kunne ikke oppdatere listen. Prøv igjen.');
}
};
@@ -31,8 +46,8 @@ export function ListToggleButton({ titleId, inMyList, variant = 'primary' }: Pro