#!/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] $*"; } # ─── 0. Ensure curl is available ───────────────────────────────────────────── if ! command -v curl > /dev/null 2>&1; then apk add --no-cache curl > /dev/null 2>&1 fi # ─── 1. Wait for PocketBase to be ready ────────────────────────────────────── log "waiting for PocketBase at $PB_URL ..." until curl -sf "$PB_URL/api/health" > /dev/null 2>&1; do sleep 2 done log "PocketBase is up" # ─── 2. Ensure the superuser exists, then authenticate ─────────────────────── # # The muchobien/pocketbase image does NOT auto-create a superuser from env vars. # On a fresh install PocketBase exposes a one-time install JWT in its log output # at /pb_data/logs/ — but we can't read that from here. # # Strategy: # a) Try to auth normally (works on subsequent runs once the account exists). # b) If that returns 400/401, PocketBase is fresh. Use the install token # obtained from the /_/ redirect Location header (PocketBase v0.23+). log "ensuring superuser $PB_EMAIL exists ..." # Try to get the install token from the /_/ redirect Location header. LOCATION=$(curl -sf -o /dev/null -w "%{redirect_url}" "$PB_URL/_/" 2>/dev/null || true) if echo "$LOCATION" | grep -q "pbinstal/"; then INSTALL_TOKEN=$(echo "$LOCATION" | sed 's|.*pbinstal/||' | tr -d ' \r\n') log "install token found — creating superuser via install endpoint" curl -sf -X POST "$PB_URL/api/collections/_superusers/records" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $INSTALL_TOKEN" \ -d "{\"email\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\",\"passwordConfirm\":\"$PB_PASSWORD\"}" \ > /dev/null 2>&1 || true log "superuser create attempted (may already exist)" fi # ─── 3. Authenticate and obtain a superuser token ──────────────────────────── log "authenticating as $PB_EMAIL ..." AUTH_RESPONSE=$(curl -sf -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \ -H "Content-Type: application/json" \ -d "{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_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" # ─── 4. Helpers ─────────────────────────────────────────────────────────────── create_collection() { NAME="$1" BODY="$2" STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "$PB_URL/api/collections" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d "$BODY") 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 } # ensure_field COLLECTION FIELD_NAME FIELD_TYPE # # Checks whether FIELD_NAME exists in COLLECTION's schema. If it is missing, # sends a PATCH with the full current fields list plus the new field appended. ensure_field() { COLL="$1" FIELD_NAME="$2" FIELD_TYPE="$3" SCHEMA=$(curl -sf \ -H "Authorization: Bearer $TOKEN" \ "$PB_URL/api/collections/$COLL" 2>/dev/null) # Use python3 to reliably parse the JSON schema. PARSED=$(echo "$SCHEMA" | python3 -c " import sys, json try: d = json.load(sys.stdin) fields = d.get('fields', []) exists = any(f.get('name') == '$FIELD_NAME' for f in fields) print('exists=' + str(exists)) print('id=' + d.get('id', '')) if not exists: fields.append({'name': '$FIELD_NAME', 'type': '$FIELD_TYPE'}) print('fields=' + json.dumps(fields)) except Exception as e: print('error=' + str(e)) " 2>/dev/null) if echo "$PARSED" | grep -q "^exists=True"; then log "field $COLL.$FIELD_NAME already exists — skipping" return fi COLLECTION_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//') if [ -z "$COLLECTION_ID" ]; then log "WARNING: could not get id for collection $COLL — skipping ensure_field" return fi NEW_FIELDS=$(echo "$PARSED" | grep "^fields=" | sed 's/^fields=//') PATCH_BODY="{\"fields\":${NEW_FIELDS}}" STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ -X PATCH "$PB_URL/api/collections/$COLLECTION_ID" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d "$PATCH_BODY") case "$STATUS" in 200|201) log "patched $COLL — added field: $FIELD_NAME ($FIELD_TYPE)" ;; *) log "WARNING: patch returned $STATUS when adding $FIELD_NAME to $COLL" ;; esac } # ─── 5. 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"}, {"name": "avatar_url", "type": "text"} ] }' create_collection "user_settings" '{ "name": "user_settings", "type": "base", "fields": [ {"name": "session_id", "type": "text", "required": true}, {"name": "user_id", "type": "text"}, {"name": "auto_next", "type": "bool"}, {"name": "voice", "type": "text"}, {"name": "speed", "type": "number"}, {"name": "updated", "type": "date"} ] }' # ─── 6. Schema migrations (idempotent field additions) ─────────────────────── # Ensures fields added after initial deploy are present in existing instances. ensure_field "progress" "user_id" "text" ensure_field "progress" "audio_time" "number" ensure_field "user_settings" "user_id" "text" ensure_field "app_users" "avatar_url" "text" create_collection "book_comments" '{ "name": "book_comments", "type": "base", "fields": [ {"name": "slug", "type": "text", "required": true}, {"name": "user_id", "type": "text"}, {"name": "username", "type": "text"}, {"name": "body", "type": "text", "required": true}, {"name": "upvotes", "type": "number"}, {"name": "downvotes", "type": "number"}, {"name": "created", "type": "date"}, {"name": "parent_id", "type": "text"} ] }' create_collection "comment_votes" '{ "name": "comment_votes", "type": "base", "fields": [ {"name": "comment_id", "type": "text", "required": true}, {"name": "user_id", "type": "text"}, {"name": "session_id", "type": "text", "required": true}, {"name": "vote", "type": "text", "required": true} ] }' create_collection "scraping_tasks" '{ "name": "scraping_tasks", "type": "base", "fields": [ {"name": "kind", "type": "text"}, {"name": "target_url", "type": "text"}, {"name": "from_chapter", "type": "number"}, {"name": "to_chapter", "type": "number"}, {"name": "worker_id", "type": "text"}, {"name": "status", "type": "text", "required": true}, {"name": "books_found", "type": "number"}, {"name": "chapters_scraped", "type": "number"}, {"name": "chapters_skipped", "type": "number"}, {"name": "errors", "type": "number"}, {"name": "error_message", "type": "text"}, {"name": "started", "type": "date"}, {"name": "finished", "type": "date"} ] }' create_collection "audio_jobs" '{ "name": "audio_jobs", "type": "base", "fields": [ {"name": "cache_key", "type": "text", "required": true}, {"name": "slug", "type": "text", "required": true}, {"name": "chapter", "type": "number", "required": true}, {"name": "voice", "type": "text"}, {"name": "worker_id", "type": "text"}, {"name": "status", "type": "text", "required": true}, {"name": "error_message", "type": "text"}, {"name": "started", "type": "date"}, {"name": "finished", "type": "date"} ] }' create_collection "user_library" '{ "name": "user_library", "type": "base", "fields": [ {"name": "session_id", "type": "text", "required": true}, {"name": "user_id", "type": "text"}, {"name": "slug", "type": "text", "required": true}, {"name": "saved_at", "type": "date"} ] }' create_collection "user_sessions" '{ "name": "user_sessions", "type": "base", "fields": [ {"name": "user_id", "type": "text", "required": true}, {"name": "session_id", "type": "text", "required": true}, {"name": "user_agent", "type": "text"}, {"name": "ip", "type": "text"}, {"name": "created_at", "type": "date"}, {"name": "last_seen", "type": "date"} ] }' create_collection "user_subscriptions" '{ "name": "user_subscriptions", "type": "base", "fields": [ {"name": "follower_id", "type": "text", "required": true}, {"name": "followee_id", "type": "text", "required": true}, {"name": "created", "type": "date"} ] }' # ─── 7. Post-initial-deploy field additions ─────────────────────────────────── # heartbeat_at is used by the backend runner to detect stale tasks. ensure_field "scraping_tasks" "heartbeat_at" "date" ensure_field "audio_jobs" "heartbeat_at" "date" log "all collections ready"