#!/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/" | 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 } # 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. # Uses only busybox sh + wget + sed/awk — no python/jq required. ensure_field() { COLL="$1" FIELD_NAME="$2" FIELD_TYPE="$3" SCHEMA=$(wget -qO- \ --header="Authorization: Bearer $TOKEN" \ "$PB_URL/api/collections/$COLL" 2>/dev/null) # Check if the field already exists (look for "name":"" in the fields array) if echo "$SCHEMA" | grep -q "\"name\":\"$FIELD_NAME\""; then log "field $COLL.$FIELD_NAME already exists — skipping" return fi COLLECTION_ID=$(echo "$SCHEMA" | sed 's/.*"id":"\([^"]*\)".*/\1/') if [ -z "$COLLECTION_ID" ] || [ "$COLLECTION_ID" = "$SCHEMA" ]; then log "WARNING: could not get id for collection $COLL — skipping ensure_field" return fi # Extract current fields array (everything between the outermost [ ] of "fields":[...]) # and append the new field object before the closing bracket. CURRENT_FIELDS=$(echo "$SCHEMA" | sed 's/.*"fields":\(\[.*\]\).*/\1/') # Strip the trailing ] and append the new field TRIMMED=$(echo "$CURRENT_FIELDS" | sed 's/]$//') NEW_FIELDS="${TRIMMED},{\"name\":\"${FIELD_NAME}\",\"type\":\"${FIELD_TYPE}\"}]" PATCH_BODY="{\"fields\":${NEW_FIELDS}}" 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/" | awk '{print $2}') 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 } # ─── 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"}, {"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"} ] }' # ─── 5. 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"} ] }' 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} ] }' log "all collections ready"