stammtisch-hersbruck/backend
Daniel Michelberger 9f075f0f6c feat: Uhrenabgleich und Ausgang fuer Zeiten ohne Netz
Eine Stage laesst sich nicht wiederholen. Zwei Dinge durften deshalb
nicht so bleiben, wie sie waren.

Erstens die Uhr. Zeiten entstehen auf den Geraeten an der Strecke — nur
so laesst sich im Funkloch ueberhaupt stoppen. Damit steckte aber der
Versatz zweier Telefonuhren in jeder Dauer, bei der einer am Start und
ein anderer im Ziel drueckt. Jedes Geraet gleicht sich jetzt gegen den
Server ab (Cristian: t0 merken, Serverzeit holen, t1 merken, Versatz =
T + RTT/2 - t1), sieben Proben, Median ueber die schnellste Haelfte. Der
Versatz liegt im localStorage und wird auf jeden Zeitstempel gerechnet.

Dafuer eine eigene Route /api/clock in den Hooks: Der Date-Header jeder
gewoehnlichen Antwort hat nach RFC 9110 Sekundenaufloesung, allein
daraus folgte ein Fehler von bis zu einer halben Sekunde — auf einer
Stage der Unterschied zwischen Platz eins und Platz drei.

Zweitens das Netz. Scheitert die Uebertragung eines Starts oder Stopps
an einem Funkloch, wandert er in einen Ausgang im localStorage statt in
einen Fehler und geht raus, sobald wieder Empfang da ist. Die
massgebliche Zeit ist die des Druckens, nicht die der Uebertragung. Ein
Stopp kann dabei auf einen Start warten, der selbst noch aussteht.

Unterschieden wird streng zwischen Funkloch und Absage: Eine abgelehnte
Anfrage — fehlende Rechte, ungueltige Daten — wird gemeldet und nicht
aufgefangen, sonst sammelte der Ausgang stumm Eintraege, die auch beim
naechsten Versuch scheitern.

Die Leiste der laufenden Zeiten zeigt nicht uebertragene Zeiten ganz
oben und erscheint dafuer auch dann, wenn gerade nichts laeuft.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 23:00:40 +02:00
..
pb_hooks feat: Uhrenabgleich und Ausgang fuer Zeiten ohne Netz 2026-09-07 23:00:40 +02:00
pb_migrations refactor: Aus Runs werden Stages, bis in die Datenbank 2026-09-07 22:16:22 +02:00
.env.example chore: PocketBase auf 0.39.11 aktualisieren 2026-08-14 15:52:00 +02:00
.gitignore chore: Repo-Struktur mit frontend/ und backend/ aufsetzen 2026-08-06 13:16:43 +02:00
docker-compose.yaml chore: PocketBase auf 0.39.11 aktualisieren 2026-08-14 15:52:00 +02:00
Dockerfile chore: PocketBase auf 0.39.11 aktualisieren 2026-08-14 15:52:00 +02:00
entrypoint.sh chore: Repo-Struktur mit frontend/ und backend/ aufsetzen 2026-08-06 13:16:43 +02:00
README.md refactor: Aus Runs werden Stages, bis in die Datenbank 2026-09-07 22:16:22 +02:00

stammtisch-hersbruck.de — Backend (PocketBase)

PocketBase-Instanz für Events, Läufe, Fahrer, Zeiten und Teams.

Lokal starten

.env aus der Vorlage anlegen (die .env selbst steht in .gitignore):

cp .env.example .env

und die Werte eintragen — .env.example beschreibt jede Variable. Dann:

docker compose up -d --build

Admin-UI: http://127.0.0.1:8090/_/ — Login mit SUPERUSER_EMAIL/SUPERUSER_PASSWORD.

Damit das Frontend gegen diese Instanz läuft, in ../frontend/.env PUBLIC_PB_URL=http://127.0.0.1:8090 setzen.

Schema

Das Schema liegt als versionierte Migration in pb_migrations/ und wird beim Start automatisch angewendet. Es enthält die sechs fachlichen Collections users, teams, events, stages, riders, times.

1754400000_init_schema.js ist ein Snapshot der produktiven Instanz. Er wurde per GET /api/collections abgezogen und ruft importCollections mit deleteMissing = false auf — die Migration legt an und aktualisiert, löscht aber nichts, was nicht im Snapshot steht.

Schema-Änderungen aus dem Admin-UI holen

pb_migrations/ wird nicht in den Container gemountet — die Migrationen kommen über COPY ins Image (siehe Dockerfile). Das ist Absicht: Ein Bind-Mount überdeckt das Image-Verzeichnis, und auf einem Deployment-Host ohne ausgechecktes Repo legt Docker dort ein leeres Verzeichnis an. PocketBase findet dann keine Migration und startet mit einer Datenbank ohne Collections.

Die Kehrseite: Eine im Admin-UI erzeugte Migration liegt zunächst nur im Container und muss von Hand herausgeholt werden:

# Welche Dateien sind dazugekommen?
docker compose exec pocketbase ls /pb/pb_migrations

# Die neue Datei ins Repo kopieren und committen
docker compose cp pocketbase:/pb/pb_migrations/<datei> ./pb_migrations/

Ohne diesen Schritt ist die Änderung beim nächsten --build verloren.

Ein frisch gestarteter Container hat damit dasselbe Schema wie die produktive Instanz — aber keine Daten.

Regeln: der Login-Vorspann ist Pflicht

Jede Regel, die @request.auth.id erwähnt, beginnt mit @request.auth.id != "" && (…). Das ist kein Stilmittel, sondern schließt ein Loch:

Eine leere Relation ist in PocketBase gleich dem leeren @request.auth.id einer anonymen Anfrage. team.admins.id ?= @request.auth.id ist damit für jedes Team ohne Admins wahr — und ohne Admins ist jedes frisch angelegte Team. Dasselbe gilt für jede optionale Relation: created_by, stewards, rider.user.

Nachgewiesen an einer lokalen Instanz mit dem Schema aus diesem Repo: Ein POST auf /api/collections/riders/records ohne jeden Token legte den Fahrer an. Betroffen waren nach demselben Muster teams (update), riders, trails, trail_versions, trail_flags, trail_markers, trail_comments, event_series und event_participants.

1754501700_rules_require_login.js setzt den Vorspann vor jede betroffene Regel. Er kann nichts erlauben, was vorher verboten war — er nimmt nur die anonyme Anfrage heraus, die keine dieser Regeln je erfüllen sollte. Gelesen werden die Regeln aus der Datenbank, eine Handänderung aus dem Admin-UI wird also abgesichert und nicht überschrieben.

Nach dem Deploy einmal gegenprüfen — das muss scheitern:

curl -X POST https://api.stammtisch-hersbruck.de/api/collections/riders/records \
  -H 'Content-Type: application/json' \
  -d '{"team":"<TEAM_ID>","name":"Test"}'

Wer eine neue Regel schreibt, setzt den Vorspann mit. Sonst reißt dasselbe Loch an der nächsten Stelle wieder auf.

1754501300 scheitert auf einer frischen Datenbank

Bekannt und nicht behoben: Bei docker compose up --build mit leerem pb_data legt der Snapshot 1754400000 die Collection riders bereits ohne das Feld event an. 1754501300_event_participants.js sucht danach mit findRecordsByFilter('riders', 'event != "") und bricht mit unknown field "event" ab — PocketBase startet dann gar nicht.

Auf der produktiven Instanz tritt das nicht auf, dort gibt es das Feld noch. Angefasst wurde die Migration deshalb nicht; wer lokal von Null startet, braucht diese eine Zeile:

const existing = riders.fields.getByName('event')
    ? app.findRecordsByFilter('riders', 'event != ""', '', 0, 0)
    : []

Hooks (pb_hooks/)

Serverseitiges JavaScript, das PocketBase beim Start lädt (--hooksDir). Wie pb_migrations/ kommt das Verzeichnis per COPY ins Image — eine Änderung wirkt also erst nach docker compose up -d --build.

Bisher liegt dort ein Anliegen: die öffentlichen Routen der Einladungslinks.

Datei Zweck
team_invites.pb.js GET /api/team-invites/{token}, POST …/accept, POST …/join
team_invites_lib.js Gemeinsame Helfer der drei Routen

Warum überhaupt ein Hook? Wer über einen Link beitritt, ist noch nicht angemeldet und muss drei Dinge auf einmal tun, die keine Collection-Regel einzeln erlauben darf: ein Konto anlegen, sich in teams.users eintragen und einen Fahrer anlegen. Ein Hook arbeitet an den Regeln vorbei und kann genau diese Kombination erlauben — gegen einen gültigen Token und sonst nicht.

Zwei Fallstricke, an denen die erste Fassung scheiterte:

  1. Jeder Route-Handler läuft in einer eigenen JS-Laufzeit. Funktionen, die in derselben Datei neben routerAdd stehen, sind im Handler nicht sichtbar (ReferenceError). Gemeinsamer Code gehört deshalb in ein Modul, das der Handler sich per require(`${__hooks}/…`) selbst holt.
  2. Was record.get() bei einer Mehrfach-Relation liefert, ist ein Go-Slice und kein JS-Array — indexOf und Konsorten sind darauf nicht verlässlich. Anhängen geht über den Modifier record.set('users+', id), Prüfen über einen Filter.

Datenpersistenz

Sämtliche Laufzeitdaten liegen in der SQLite-DB unter /pb/pb_data: Datensätze, Uploads, die SMTP-Einstellungen und der Superuser. Ohne gemountetes Volume auf diesem Pfad sind sie nach jedem Redeploy verloren.

Das Volume-Mapping ./pb_data:/pb/pb_data steht in der docker-compose.yaml, damit die Persistenz-Konfiguration versioniert im Repo liegt.

Trügerisches Signal: Die Collections kommen aus der Migration und sind nach einem Redeploy auch dann da, wenn gar kein Volume existiert. Sie taugen nicht als Nachweis funktionierender Persistenz — das zeigt nur ein Datensatz, der in keiner Migration steht.

Mount prüfen:

docker inspect <container> --format '{{json .Mounts}}' | jq

Erscheint dort kein Eintrag mit "Destination": "/pb/pb_data", fehlt die Persistenz.

pb_data/ steht in .gitignore und gehört dort auch hin.

Superuser-Verhalten

entrypoint.sh ruft superuser create auf — bewusst nicht upsert. Existiert der Account bereits, scheitert create mit „Value must be unique" und das Passwort bleibt unangetastet. Eine im Admin-UI vorgenommene Passwortänderung überlebt damit jeden Redeploy.

Situation Verhalten
Erster Start, Variablen gesetzt Superuser wird angelegt
Neustart, Account existiert „Superuser existiert bereits — Passwort bleibt unverändert"
Passwort im UI geändert, dann Neustart UI-Passwort gilt weiter, Env wird ignoriert
Variablen nicht gesetzt Übersprungen, PocketBase gibt Installer-Link im Log aus
Passwort zu kurz (< 8 Zeichen) Container bricht mit Exitcode 1 ab

Passwort später ändern: im Admin-UI, nicht über die Env — eine Änderung der Env-Variable hat keine Wirkung mehr, sobald der Account existiert.

PocketBase aktualisieren

Die Version ist über das Build-Arg PB_VERSION gepinnt (Dockerfile und docker-compose.yaml). Wert hochsetzen, committen, neu bauen.

Ein latest gibt es bewusst nicht: Der GitHub-Asset heißt pocketbase_<version>_linux_amd64.zip, enthält die Version also im Dateinamen. Der Pin ist auch gewollt — sonst zieht jeder Rebuild unangekündigt eine neue Version, inklusive möglicher DB-Schema-Migrationen.

Vor einem Update: Changelog prüfen (https://github.com/pocketbase/pocketbase/releases) und pb_data sichern — PocketBase migriert die DB beim Start automatisch, ein Downgrade ist danach nicht mehr ohne Weiteres möglich.