#!/bin/sh # pb-init-v3.sh — idempotent PocketBase bootstrap for the v3 stack. # # Safe to re-run: existing collections and fields are silently skipped. # # Env vars (defaults match docker-compose.yml): # POCKETBASE_URL http://pocketbase:8090 # POCKETBASE_ADMIN_EMAIL admin@libnovel.local # POCKETBASE_ADMIN_PASSWORD changeme123 set -e PB="${POCKETBASE_URL:-http://pocketbase:8090}" EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" PASS="${POCKETBASE_ADMIN_PASSWORD:-changeme123}" log() { printf '[pb-init] %s\n' "$*"; } # ── 0. Ensure dependencies ──────────────────────────────────────────────────── command -v curl > /dev/null 2>&1 || apk add --no-cache curl > /dev/null 2>&1 command -v python3 > /dev/null 2>&1 || apk add --no-cache python3 > /dev/null 2>&1 # ── 1. Wait for PocketBase ──────────────────────────────────────────────────── log "waiting for PocketBase..." until curl -sf "$PB/api/health" > /dev/null 2>&1; do sleep 2; done log "PocketBase ready" # ── 2. Bootstrap superuser (first-run only) ─────────────────────────────────── LOCATION=$(curl -sf -o /dev/null -w "%{redirect_url}" "$PB/_/" 2>/dev/null || true) if echo "$LOCATION" | grep -q "pbinstal/"; then TOKEN=$(echo "$LOCATION" | sed 's|.*pbinstal/||' | tr -d ' \r\n') curl -sf -X POST "$PB/api/collections/_superusers/records" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\",\"passwordConfirm\":\"$PASS\"}" \ > /dev/null 2>&1 || true log "superuser created" fi # ── 3. Authenticate ─────────────────────────────────────────────────────────── AUTH=$(curl -sf -X POST "$PB/api/collections/_superusers/auth-with-password" \ -H "Content-Type: application/json" \ -d "{\"identity\":\"$EMAIL\",\"password\":\"$PASS\"}") TOK=$(echo "$AUTH" | sed 's/.*"token":"\([^"]*\)".*/\1/') [ -z "$TOK" ] || [ "$TOK" = "$AUTH" ] && { log "ERROR: auth failed"; exit 1; } log "authenticated" # ── Helpers ─────────────────────────────────────────────────────────────────── # create NAME BODY — POST collection; 400/422 = already exists, treated as ok. create() { NAME="$1"; BODY="$2" STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "$PB/api/collections" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOK" \ -d "$BODY") case "$STATUS" in 200|201) log "created: $NAME" ;; 400|422) log "exists (skip): $NAME" ;; *) log "WARNING: $NAME returned $STATUS" ;; esac } # add_field COLLECTION FIELD_NAME FIELD_TYPE # Fetches current schema, appends field if absent, PATCHes collection. # Requires python3 for safe JSON manipulation. add_field() { COLL="$1"; FIELD="$2"; TYPE="$3" SCHEMA=$(curl -sf -H "Authorization: Bearer $TOK" "$PB/api/collections/$COLL" 2>/dev/null) # Check existence and extract collection id + fields via python3 PARSED=$(echo "$SCHEMA" | python3 -c " import sys, json d = json.load(sys.stdin) fields = d.get('fields', []) exists = any(f.get('name') == '$FIELD' for f in fields) print('exists=' + str(exists)) print('id=' + d.get('id', '')) if not exists: fields.append({'name': '$FIELD', 'type': '$TYPE'}) print('fields=' + json.dumps(fields)) " 2>/dev/null) if echo "$PARSED" | grep -q "^exists=True"; then log "field exists (skip): $COLL.$FIELD"; return fi COLL_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//') [ -z "$COLL_ID" ] && { log "WARNING: cannot resolve id for $COLL"; return; } NEW_FIELDS=$(echo "$PARSED" | grep "^fields=" | sed 's/^fields=//') STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ -X PATCH "$PB/api/collections/$COLL_ID" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOK" \ -d "{\"fields\":${NEW_FIELDS}}") case "$STATUS" in 200|201) log "added field: $COLL.$FIELD ($TYPE)" ;; *) log "WARNING: add_field $COLL.$FIELD returned $STATUS" ;; esac } # ── 4. Collections ──────────────────────────────────────────────────────────── create "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":"text"} ]}' create "chapters_idx" '{ "name":"chapters_idx","type":"base","fields":[ {"name":"slug", "type":"text", "required":true}, {"name":"number","type":"number", "required":true}, {"name":"title", "type":"text"} ]}' create "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"} ]}' create "progress" '{ "name":"progress","type":"base","fields":[ {"name":"session_id","type":"text", "required":true}, {"name":"slug", "type":"text", "required":true}, {"name":"chapter", "type":"number"}, {"name":"user_id", "type":"text"}, {"name":"audio_time","type":"number"}, {"name":"updated", "type":"text"} ]}' create "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"}, {"name":"heartbeat_at", "type":"date"} ]}' create "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"}, {"name":"heartbeat_at", "type":"date"} ]}' create "app_users" '{ "name":"app_users","type":"base","fields":[ {"name":"username", "type":"text","required":true}, {"name":"password_hash", "type":"text"}, {"name":"role", "type":"text"}, {"name":"avatar_url", "type":"text"}, {"name":"created", "type":"text"}, {"name":"email", "type":"text"}, {"name":"email_verified", "type":"bool"}, {"name":"verification_token", "type":"text"}, {"name":"verification_token_exp","type":"text"}, {"name":"oauth_provider", "type":"text"}, {"name":"oauth_id", "type":"text"} ]}' create "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":"text"}, {"name":"last_seen", "type":"text"} ]}' create "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":"text"} ]}' create "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":"text"} ]}' create "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":"text"} ]}' create "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"}, {"name":"upvotes", "type":"number"}, {"name":"downvotes","type":"number"}, {"name":"parent_id","type":"text"}, {"name":"created", "type":"text"} ]}' create "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"}, {"name":"vote", "type":"text"} ]}' create "translation_jobs" '{ "name":"translation_jobs","type":"base","fields":[ {"name":"cache_key", "type":"text", "required":true}, {"name":"slug", "type":"text", "required":true}, {"name":"chapter", "type":"number","required":true}, {"name":"lang", "type":"text", "required":true}, {"name":"worker_id", "type":"text"}, {"name":"status", "type":"text", "required":true}, {"name":"error_message","type":"text"}, {"name":"started", "type":"date"}, {"name":"finished", "type":"date"}, {"name":"heartbeat_at", "type":"date"} ]}' create "discovery_votes" '{ "name":"discovery_votes","type":"base","fields":[ {"name":"session_id","type":"text","required":true}, {"name":"user_id", "type":"text"}, {"name":"slug", "type":"text","required":true}, {"name":"action", "type":"text","required":true} ]}' # ── 5. Field migrations (idempotent — adds fields missing from older installs) ─ add_field "scraping_tasks" "heartbeat_at" "date" add_field "audio_jobs" "heartbeat_at" "date" add_field "progress" "user_id" "text" add_field "progress" "audio_time" "number" add_field "progress" "updated" "text" add_field "books" "meta_updated" "text" add_field "app_users" "email" "text" add_field "app_users" "email_verified" "bool" add_field "app_users" "verification_token" "text" add_field "app_users" "verification_token_exp" "text" add_field "app_users" "oauth_provider" "text" add_field "app_users" "oauth_id" "text" add_field "app_users" "polar_customer_id" "text" add_field "app_users" "polar_subscription_id" "text" log "done"