--- name: pocketbase-deployment description: "PocketBase als Backend auf Linux-VPS deployen: Installation, systemd, Collection-Schema anlegen (0.25 Quirks!), Filter-Syntax, API-Integration mit Flask. Vermeidet die typischen 400/404-Fehler." version: 1.0.0 author: mehlzoerwer platforms: [linux] --- # PocketBase Deployment (Linux VPS) PocketBase als leichtgewichtiges Backend für Web-Projekte (PersoEnt, Weltklasse-OS). Getestet mit v0.25.0 auf Ubuntu 24.04. ## Installation ```bash cd /tmp && curl -sL https://github.com/pocketbase/pocketbase/releases/download/v0.25.0/pocketbase_0.25.0_linux_amd64.zip -o pb.zip unzip -o pb.zip -d /opt/pocketbase /opt/pocketbase/pocketbase --version ``` **systemd** (`/etc/systemd/system/pocketbase.service`): ```ini [Unit] Description=PocketBase After=network.target [Service] User=www-data Group=www-data WorkingDirectory=/opt/pocketbase ExecStart=/opt/pocketbase/pocketbase serve --http=127.0.0.1:8090 Restart=always RestartSec=5 [Install] WantedBy=multi-user.target ``` ```bash mkdir -p /opt/pocketbase/pb_data && chown -R www-data:www-data /opt/pocketbase systemctl daemon-reload && systemctl enable pocketbase && systemctl start pocketbase curl -s http://127.0.0.1:8090/api/health # {"message":"API is healthy."} ``` ## Superuser + Auth-Token ```bash # Superuser via CLI erstellen (API-Endpoint /_superusers existert NICHT zum Anlegen!) /opt/pocketbase/pocketbase superuser upsert admin@projekt.local P4ssw0rd! # Token holen: TOKEN=$(curl -s -X POST http://127.0.0.1:8090/api/collections/_superusers/auth-with-password \ -H 'Content-Type: application/json' \ -d '{"identity":"admin@projekt.local","password":"P4ssw0rd!"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") ``` ⚠️ **Token läuft lange** (exp ~1 Jahr bei `superuser upsert`), trotzdem bei API-Code hartcodieren nur für Prototypen. Produktion: OAuth/Middleware. ## 🔥 Collection-Schema anlegen (0.25 QUIRKS!) **POST `/api/collections` mit `schema`-Array IGNORIERT das Schema komplett!** Die Collection wird OHNE Felder erstellt (nur `id`). Das führt zu 400 bei jedem Filter. **Fix — Schema NACHTRÄGLICH via PATCH mit `id`-Feldern:** ```bash curl -s -X PATCH http://127.0.0.1:8090/api/collections/health_records \ -H "Authorization: $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "fields": [ {"name":"weight","type":"number","required":true,"min":0,"max":500,"id":"w1","presentable":false,"system":false}, {"name":"blood_pressure","type":"text","required":true,"min":3,"max":10,"id":"bp1","presentable":false,"system":false}, {"name":"pulse","type":"number","required":true,"min":0,"max":300,"id":"p1","presentable":false,"system":false}, {"name":"user_id","type":"text","required":true,"min":1,"max":100,"id":"u1","presentable":false,"system":false} ] }' ``` → Antwort zeigt `FIELDS: ['id', 'weight', 'blood_pressure', 'pulse', 'user_id']` ✅ **Jedes Feld BRAUCHT eine `id`!** Ohne `id` wird es abgelehnt. ## 🔥 Filter-Syntax (0.25) | Falsch | Richtig | Warum | |--------|---------|-------| | `filter=user_id='x'` | `filter=user_id = "x"` | Leerzeichen + Doppelte Anführungszeichen | | `sort=-created` | `sort=-id` | `created` ist System-Feld → 400 bei Sort. **ABER `sort=-id` funktioniert (200):** PocketBase-IDs sind chronologisch aufsteigend, `-id` = neueste zuerst. VERIFIZIERT (2026-08-06): `sort=created` und `sort=-created` geben 400, `sort=-id` und `sort=weight` geben 200. Für "neueste zuerst" immer `sort=-id` nutzen. | **Beispiel (funktioniert):** ``` GET /api/collections/health_records/records?filter=user_id%20%3D%20%22x%22&page=1&perPage=10 ``` **Flask-Integration (Backend):** ```python import requests POCKETBASE_URL = "http://127.0.0.1:8090" PB_TOKEN = "" # für Filter-Abfragen nötig (viewRule leer reicht nicht!) # WRITE (POST): r = requests.post(f"{POCKETBASE_URL}/api/collections/health_records/records", json={"weight":72,"blood_pressure":"120/80","pulse":68,"user_id":"x"}) # READ (GET mit Filter): r = requests.get(f"{POCKETBASE_URL}/api/collections/health_records/records", params={"filter": 'user_id = "x"', "page":1, "perPage":10}, headers={"Authorization": PB_TOKEN}) ``` ⚠️ **`viewRule: ""` reicht NICHT für Filter-Queries** — PocketBase verlangt Auth-Header bei `filter=`. Admin-Token im Backend-Code löst das. ## 🔥 Write-Schutz: leerer `createRule`/`updateRule` blockt User-Writes (400) **Symptom (2026-08-15):** PersoEnt-User mit `users/auth-with-password`-Token kann **lesen**, aber `POST /api/collections/health_records/records` → **`400 Failed to create record`**, selbst mit vollständigem Record, der exakt einem bestehenden entspricht. **Ursache:** Die Collection hat **leere `createRule` UND `updateRule`** (= nur Superuser darf schreiben; normaler User darf nur bei gesetzter Regel). Das ist **gutes Sicherheits-Design**, aber man stolpert darüber, weil der User-Read funktioniert. **Diagnose (definitiv, ohne Ratenein):** ```bash # Collection-Regeln direkt aus der SQLite-DB lesen: python3 -c " import sqlite3 c=sqlite3.connect('/opt/pocketbase/pb_data/data.db') for r in c.execute(\"SELECT name,createRule,updateRule FROM _collections WHERE name='health_records'\"): print('create:', repr(r[1]), '| update:', repr(r[2])) " # leere Regel = '' oder NULL → nur Superuser darf schreiben ``` **Fix (Hauptweg):** Für Server-Integration mit **Superuser-Token** schreiben (nicht User-Token). Siehe `## Superuser + Auth-Token` oben — `pocketbase superuser upsert ...` anlegen + `collections/_superusers/auth-with-password`-Token verwenden. Behalte das restriktive Design bei (kein offener User-Write), statt die Regel zu lockern — sensible Gesundheitsdaten. **Fallback (Option B, verifiziert 2026-08-15):** Wenn **KEIN Superuser-Passwort existiert** (Nutzer hat PB nicht aufgesetzt / kennt das Admin-PW nicht), kann der **Server-Integration-Poller direkt in die SQLite-DB schreiben** (root, läuft auf dem VPS): ```python # direkter INSERT in /opt/pocketbase/pb_data/data.db — umgeht API-createRule komplett import sqlite3, shutil, datetime, os PB_DB = "/opt/pocketbase/pb_data/data.db" os.makedirs("/opt/pocketbase/pb_data/backups", exist_ok=True) # Rolling-Backup (max 3) VOR erstem Write — Backup-Regel backups = sorted(b for b in os.listdir("/opt/pocketbase/pb_data/backups") if b.endswith(".db")) if len(backups) >= 3: os.remove(os.path.join("/opt/pocketbase/pb_data/backups", backups[0])) shutil.copy2(PB_DB, f"/opt/pocketbase/pb_data/backups/data_{datetime.datetime.now():%Y%m%d_%H%M%S}.db") con = sqlite3.connect(PB_DB); cur = con.cursor() cols = [r[1] for r in cur.execute("PRAGMA table_info('health_records')")] # vorhandene Spalten pruefen vals = {"steps":981,"stress":22,"pulse":51,"sleep_hours":7.77,"user_id":"thorsten"} sub = [c for c in vals if c in cols] # NUR vorhandene Spalten senden cur.execute(f"INSERT INTO health_records ({','.join(sub)}) VALUES ({','.join('?'*len(sub))})", [vals[c] for c in sub]); con.commit(); con.close() ``` **PocketBase-Leseseite:** direkte Writes sind via API lesbar (`sort=-id`), weil PB die SQLite-DB direkt liest — aber PB-Validierung/Trigger werden umgangen. **Nur nutzen für** einfache, single-User-Inert-Tables (Cron-Poller auf `health_records`); **nicht** für Beziehungen/Migrationen/parallele PB-Writes. Vor dem ersten Write DOM-typ trotzdem: Collection-Spalten via `PRAGMA table_info` holen (kein `datum`/`source`-Feld annehmen — es kann fehlen). NICHTS erfinden, echte COL-Check. ## 🔥 Schema-Cache-Gotcha: per ALTER TABLE ergänzte Spalte wird NICHT von der API geliefert **Symptom (2026-08-15):** Du fügst per direktem `ALTER TABLE health_records ADD COLUMN datum TEXT` eine neue Spalte hinzu und schreibst sie per Direkt-Write — aber die API liefert `datum` nicht (Record-Keys ohne das Feld), obwohl es in der SQLite-Tabelle existiert. **Ursache:** PocketBase hält sein Collection-Schema **in `_collections.fields` (separate JSON) + im RAM-Cache**. Ein rohes `ALTER TABLE` ändert die Tabelle, aber **nicht das PB-Schema** → API white-/blacklistet das Feld nicht. **Fix (verifiziert 2026-08-15):** Feld NACHträglich in PB-Schema eintragen UND Service neu laden: ```bash # 1. Backup (Backup-Regel) — cp data.db data.db.bak- # 2. Feld in _collections.fields (JSON) eintragen: python3 -c " import sqlite3, json c=sqlite3.connect('/opt/pocketbase/pb_data/data.db') row=c.execute(\"SELECT fields FROM _collections WHERE name='health_records'\").fetchone() f=json.loads(row[0]) if 'datum' not in [x.get('name') for x in f]: f.append({'name':'datum','type':'text','required':False,'system':False,'max':10,'min':0,'pattern':'','autogeneratePattern':'','primaryKey':False}) c.execute(\"UPDATE _collections SET fields=? WHERE name='health_records'\",(json.dumps(f),)); c.commit() # 3. PocketBase-Service neu laden (RAM-Schema-Cache!): " systemctl restart pocketbase # ODER systemctl restart pocketbase.service # Verifikation: API-Response-keys enthalten jetzt das Feld ``` **Ohne den `systemctl restart pocketbase` liefert die API das neue Feld NICHT** — der RAM-Cache nimmt Schema-Änderungen erst nach Neustart an. Danach: `GET .../records?filter=...&sort=-id` → `datum` taucht in Record-Keys auf. ## ⌚ Wearable-/Geräte-Daten einspeisen (COROS-MCP-Beispiel) ## Flask + Nginx Reverse-Proxy Backend unter `/var/www//backend/app.py` (systemd `persoent-api`, siehe `references/flask-deploy.md`). Nginx: ```nginx location /api/ { proxy_pass http://127.0.0.1:5000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } ``` ## Verification ```bash # Write test: curl -s -X POST https:///api/health/record -H 'Content-Type: application/json' \ -d '{"weight":72,"blood_pressure":"120/80","pulse":68,"user_id":"test"}' # → {"id":"...","message":"Health record saved successfully"} # Read test: curl -s "https:///api/health/records?user_id=test" # → {"records":[...],"total":1} ``` ## Referenzen - `references/flask-deploy.md` — Flask-App als systemd-Service + Nginx Proxy (PersoEnt-Pattern) - `references/pocketbase-quirks-19-07-2026.md` — Session-Transcript der 400-Fehler + Fixes - `references/coros-mcp-wearable-integration.md` — COROS-Sportuhr-Daten via offiziellem `coros-mcp` CLI in health_records (Login-Flow, korrekte `--tool`/`--arguments-json`-Syntax, Text-Block-Parsing) für Wearable-/Geräte-Einspeisung