#!/bin/sh # pb-init.sh — idempotent PocketBase collection bootstrap # # Creates all collections required by libnovel. Safe to re-run: POST returns # 400/422 when a collection already exists; both are treated as success. # # Required env vars (with defaults): # POCKETBASE_URL http://pocketbase:8090 # POCKETBASE_ADMIN_EMAIL admin@libnovel.local # POCKETBASE_ADMIN_PASSWORD changeme123 set -e PB_URL="${POCKETBASE_URL:-http://pocketbase:8090}" PB_EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" PB_PASSWORD="${POCKETBASE_ADMIN_PASSWORD:-changeme123}" log() { echo "[pb-init] $*"; } # ─── 1. Wait for PocketBase to be ready ────────────────────────────────────── log "waiting for PocketBase at $PB_URL ..." until wget -qO- "$PB_URL/api/health" > /dev/null 2>&1; do sleep 2 done log "PocketBase is up" # ─── 2. Authenticate and obtain a superuser token ──────────────────────────── log "authenticating as $PB_EMAIL ..." AUTH_RESPONSE=$(wget -qO- \ --header="Content-Type: application/json" \ --post-data="{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}" \ "$PB_URL/api/collections/_superusers/auth-with-password") TOKEN=$(echo "$AUTH_RESPONSE" | sed 's/.*"token":"\([^"]*\)".*/\1/') if [ -z "$TOKEN" ] || [ "$TOKEN" = "$AUTH_RESPONSE" ]; then log "ERROR: failed to obtain auth token. Response: $AUTH_RESPONSE" exit 1 fi log "auth token obtained" # ─── 3. Helpers ─────────────────────────────────────────────────────────────── create_collection() { NAME="$1" BODY="$2" STATUS=$(wget -qSO- \ --header="Content-Type: application/json" \ --header="Authorization: Bearer $TOKEN" \ --post-data="$BODY" \ "$PB_URL/api/collections" 2>&1 | grep "HTTP/" | tail -1 | awk '{print $2}') case "$STATUS" in 200|201) log "created collection: $NAME" ;; 400|422) log "collection already exists (skipped): $NAME" ;; *) log "WARNING: unexpected status $STATUS for collection: $NAME" ;; esac } # Patch a collection by name: adds any fields listed in FIELDS_JSON that are # missing from the current schema. This is idempotent and safe to re-run. ensure_fields() { NAME="$1" FIELDS_JSON="$2" # JSON array of field objects to guarantee exist # Fetch current schema CURRENT=$(wget -qO- \ --header="Authorization: Bearer $TOKEN" \ "$PB_URL/api/collections/$NAME" 2>/dev/null) COLLECTION_ID=$(echo "$CURRENT" | sed 's/.*"id":"\([^"]*\)".*/\1/') if [ -z "$COLLECTION_ID" ] || [ "$COLLECTION_ID" = "$CURRENT" ]; then log "WARNING: could not get id for collection $NAME — skipping ensure_fields" return fi # Build merged fields list via a small python snippet MERGED=$(python3 - "$CURRENT" "$FIELDS_JSON" <<'PYEOF' import sys, json current = json.loads(sys.argv[1]) desired = json.loads(sys.argv[2]) existing_names = {f["name"] for f in current.get("fields", [])} all_fields = list(current.get("fields", [])) added = [] for f in desired: if f["name"] not in existing_names: all_fields.append(f) added.append(f["name"]) print(json.dumps({"fields": all_fields, "_added": added})) PYEOF ) ADDED=$(echo "$MERGED" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(",".join(d.get("_added",[])))' 2>/dev/null) if [ -z "$ADDED" ]; then log "collection $NAME schema up-to-date" return fi PATCH_BODY=$(echo "$MERGED" | python3 -c 'import sys,json; d=json.load(sys.stdin); d.pop("_added",None); print(json.dumps(d))' 2>/dev/null) STATUS=$(wget -qSO- \ --header="Content-Type: application/json" \ --header="Authorization: Bearer $TOKEN" \ --body-data="$PATCH_BODY" \ --method=PATCH \ "$PB_URL/api/collections/$COLLECTION_ID" 2>&1 | grep "HTTP/" | tail -1 | awk '{print $2}') case "$STATUS" in 200|201) log "patched collection $NAME — added fields: $ADDED" ;; *) log "WARNING: patch returned $STATUS for collection $NAME" ;; esac } # ─── 4. Create collections (idempotent — skips if already exist) ───────────── create_collection "books" '{ "name": "books", "type": "base", "fields": [ {"name": "slug", "type": "text", "required": true}, {"name": "title", "type": "text", "required": true}, {"name": "author", "type": "text"}, {"name": "cover", "type": "text"}, {"name": "status", "type": "text"}, {"name": "genres", "type": "json"}, {"name": "summary", "type": "text"}, {"name": "total_chapters", "type": "number"}, {"name": "source_url", "type": "text"}, {"name": "ranking", "type": "number"}, {"name": "meta_updated", "type": "date"} ] }' create_collection "chapters_idx" '{ "name": "chapters_idx", "type": "base", "fields": [ {"name": "slug", "type": "text", "required": true}, {"name": "number", "type": "number", "required": true}, {"name": "title", "type": "text"}, {"name": "date_label", "type": "text"} ] }' create_collection "ranking" '{ "name": "ranking", "type": "base", "fields": [ {"name": "rank", "type": "number", "required": true}, {"name": "slug", "type": "text", "required": true}, {"name": "title", "type": "text"}, {"name": "author", "type": "text"}, {"name": "cover", "type": "text"}, {"name": "status", "type": "text"}, {"name": "genres", "type": "json"}, {"name": "source_url", "type": "text"}, {"name": "updated", "type": "date"} ] }' create_collection "progress" '{ "name": "progress", "type": "base", "fields": [ {"name": "session_id", "type": "text", "required": true}, {"name": "user_id", "type": "text"}, {"name": "slug", "type": "text", "required": true}, {"name": "chapter", "type": "number"}, {"name": "updated", "type": "date"} ] }' create_collection "audio_cache" '{ "name": "audio_cache", "type": "base", "fields": [ {"name": "cache_key", "type": "text", "required": true}, {"name": "filename", "type": "text"}, {"name": "updated", "type": "date"} ] }' create_collection "app_users" '{ "name": "app_users", "type": "base", "fields": [ {"name": "username", "type": "text", "required": true}, {"name": "password_hash", "type": "text", "required": true}, {"name": "role", "type": "text"}, {"name": "created", "type": "date"} ] }' # ─── 5. Schema migrations (idempotent field additions) ─────────────────────── # Ensures fields added after initial deploy are present in existing instances. ensure_fields "progress" '[ {"name": "user_id", "type": "text"} ]' log "all collections ready"