#!/usr/bin/env bash # # setup-schema.sh — idempotent PocketBase schema provisioning for Bookshelf. # # Creates/updates the `bookcases`, `shelves`, `books` collections exactly per # docs/SPEC.md, sets every API rule (including locking down self-registration # on `users`), and creates the required indexes. Safe to re-run any number of # times against the same instance — it diffs against what already exists. # # Requires: bash, curl, jq (no other tooling; portable to any Linux box). # # Usage: # ./setup-schema.sh [PB_URL] [PB_EMAIL] [PB_PASS] # PB_URL=http://127.0.0.1:8090 PB_EMAIL=admin@example.com PB_PASS=secret ./setup-schema.sh # # Args take priority over env vars if both are given. set -u -o pipefail # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- PB_URL="${1:-${PB_URL:-http://127.0.0.1:8090}}" PB_EMAIL="${2:-${PB_EMAIL:-}}" PB_PASS="${3:-${PB_PASS:-}}" # Strip any trailing slash so URL-joining below is predictable. PB_URL="${PB_URL%/}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CREDS_FILE="$SCRIPT_DIR/.dev-credentials" log() { printf '%s\n' "$*" >&2; } info() { log " $*"; } ok() { printf '\033[32m✓\033[0m %s\n' "$*" >&2; } fail() { printf '\033[31m✗ ERROR:\033[0m %s\n' "$*" >&2; exit 1; } # --------------------------------------------------------------------------- # Preflight # --------------------------------------------------------------------------- command -v curl >/dev/null 2>&1 || fail "curl is required but not installed." command -v jq >/dev/null 2>&1 || fail "jq is required but not installed." if [[ -z "$PB_EMAIL" || -z "$PB_PASS" ]]; then if [[ -f "$CREDS_FILE" ]]; then info "No PB_EMAIL/PB_PASS given — loading $CREDS_FILE" # shellcheck disable=SC1090 source "$CREDS_FILE" PB_EMAIL="${PB_EMAIL:-${PB_SUPERUSER_EMAIL:-}}" PB_PASS="${PB_PASS:-${PB_SUPERUSER_PASS:-}}" fi fi [[ -n "$PB_EMAIL" ]] || fail "No superuser email given. Pass it as arg 2, set PB_EMAIL, or provide $CREDS_FILE." [[ -n "$PB_PASS" ]] || fail "No superuser password given. Pass it as arg 3, set PB_PASS, or provide $CREDS_FILE." log "Bookshelf schema setup" log " Target: $PB_URL" log " Superuser: $PB_EMAIL" log "" # Reachability check with an actionable error. if ! curl -sf -o /dev/null --connect-timeout 5 "$PB_URL/api/health"; then fail "Cannot reach $PB_URL/api/health — is PocketBase running there? Check the URL and that the server is up." fi # --------------------------------------------------------------------------- # Auth # --------------------------------------------------------------------------- AUTH_RESP="$(curl -s -w '\n%{http_code}' -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg identity "$PB_EMAIL" --arg password "$PB_PASS" '{identity:$identity, password:$password}')")" AUTH_BODY="$(printf '%s' "$AUTH_RESP" | sed '$d')" AUTH_CODE="$(printf '%s' "$AUTH_RESP" | tail -n1)" if [[ "$AUTH_CODE" != "200" ]]; then fail "Superuser login failed (HTTP $AUTH_CODE). Response: $AUTH_BODY Check PB_EMAIL/PB_PASS (or $CREDS_FILE) and that this account is a _superusers record." fi TOKEN="$(printf '%s' "$AUTH_BODY" | jq -r '.token')" [[ -n "$TOKEN" && "$TOKEN" != "null" ]] || fail "Login succeeded but no token was returned. Response: $AUTH_BODY" ok "Authenticated as superuser" AUTH_HEADER="Authorization: Bearer $TOKEN" # --------------------------------------------------------------------------- # HTTP helpers # --------------------------------------------------------------------------- # Both helpers below print "\n" to stdout. Callers must NOT # rely on a global variable being set as a side effect of a function called # inside a command substitution — that runs in a subshell, so any such global # assignment is invisible to the caller once the subshell exits. Encoding the # code into the captured output itself sidesteps that entirely. # api_get PATH -> prints "\n" api_get() { local path="$1" curl -s -w '\n%{http_code}' -H "$AUTH_HEADER" "$PB_URL$path" | _reorder_code_first } # api_send METHOD PATH JSON_BODY -> prints "\n" api_send() { local method="$1" path="$2" body="$3" curl -s -w '\n%{http_code}' -X "$method" "$PB_URL$path" \ -H "$AUTH_HEADER" -H "Content-Type: application/json" -d "$body" | _reorder_code_first } # Reads "\n" (curl's -w appends code as the final line) and # re-emits it as "\n" so callers can cleanly read one line for # the code and the rest for the body. _reorder_code_first() { local all code body all="$(cat)" code="$(printf '%s' "$all" | tail -n1)" body="$(printf '%s' "$all" | sed '$d')" printf '%s\n%s' "$code" "$body" } # split_response VAR_PREFIX RESPONSE — sets ${VAR_PREFIX}_CODE and ${VAR_PREFIX}_BODY # from a "\n" string, in the *caller's* shell (no subshell). split_response() { local prefix="$1" resp="$2" printf -v "${prefix}_CODE" '%s' "$(printf '%s' "$resp" | head -n1)" printf -v "${prefix}_BODY" '%s' "$(printf '%s' "$resp" | tail -n +2)" } # get_collection_json NAME -> prints collection JSON, or empty string if 404 get_collection_json() { local name="$1" resp resp="$(api_get "/api/collections/$name")" split_response GC "$resp" if [[ "$GC_CODE" == "200" ]]; then printf '%s' "$GC_BODY" elif [[ "$GC_CODE" == "404" ]]; then printf '' else fail "Unexpected response fetching collection '$name' (HTTP $GC_CODE): $GC_BODY" fi } # --------------------------------------------------------------------------- # Field merging # # PocketBase's collection PATCH/POST replaces the *entire* fields array with # whatever is sent — any field omitted from the payload is dropped. To stay # idempotent (and to keep each field's existing internal id stable across # re-runs, which matters for indexes and relations), we merge our desired # field definitions over whatever fields already exist by matching on name: # existing fields get their "id" preserved and properties overwritten; new # fields are appended with no id (PocketBase assigns one on save). # --------------------------------------------------------------------------- # merge_fields EXISTING_FIELDS_JSON DESIRED_FIELDS_JSON -> merged fields array merge_fields() { local existing="$1" desired="$2" jq -n --argjson existing "$existing" --argjson desired "$desired" ' ($existing // []) as $ex | ($desired // []) as $des | ($ex | map({(.name): .id}) | add // {}) as $idByName | ($ex | map(select(.name == "id"))) as $idField | $idField + ($des | map( . as $f | if ($idByName[$f.name] != null) then $f + {id: $idByName[$f.name]} else $f end )) ' } # --------------------------------------------------------------------------- # Collection definitions (per SPEC.md) — filled in after bookcases/shelves # ids are known, since relation fields need the target collectionId. # --------------------------------------------------------------------------- AUTH_RULE='@request.auth.id != ""' apply_collection() { local name="$1" type="$2" desired_fields="$3" indexes_json="$4" local existing existing_id existing_fields merged payload method path resp existing="$(get_collection_json "$name")" if [[ -z "$existing" ]]; then info "Creating collection '$name'..." method="POST" path="/api/collections" existing_fields="[]" existing_id="" else info "Updating collection '$name'..." method="PATCH" path="/api/collections/$name" existing_fields="$(printf '%s' "$existing" | jq -c '.fields')" existing_id="$(printf '%s' "$existing" | jq -r '.id')" fi merged="$(merge_fields "$existing_fields" "$desired_fields")" payload="$(jq -n \ --arg name "$name" \ --arg type "$type" \ --argjson fields "$merged" \ --argjson indexes "$indexes_json" \ --arg rule "$AUTH_RULE" \ '{ name: $name, type: $type, fields: $fields, indexes: $indexes, listRule: $rule, viewRule: $rule, createRule: $rule, updateRule: $rule, deleteRule: $rule }')" resp="$(api_send "$method" "$path" "$payload")" split_response AC "$resp" if [[ "$AC_CODE" != "200" ]]; then fail "Failed to save collection '$name' (HTTP $AC_CODE): $AC_BODY" fi ok "Collection '$name' ready (rules = auth-required)" # Return the collection id via global for callers that need it. COLLECTION_ID="$(printf '%s' "$AC_BODY" | jq -r '.id')" # PocketBase auto-writes a pb_migrations/*.js snapshot per collection save, # named "__.js". Migrations with the same # second-resolution timestamp are replayed in filename (alphabetical) order # on a fresh instance, NOT creation order — so e.g. "created_books" can run # before "created_shelves" even though shelves must exist first for the # relation field. A 1s pause between collection saves keeps timestamps # strictly increasing so a fresh instance replays them in the right order. sleep 1 } # --------------------------------------------------------------------------- # 1) bookcases # --------------------------------------------------------------------------- BOOKCASES_FIELDS='[ {"name":"name","type":"text","required":true}, {"name":"note","type":"text","required":false}, {"name":"position","type":"number","required":false}, {"name":"deleted","type":"bool","required":false}, {"name":"created","type":"autodate","onCreate":true,"onUpdate":false}, {"name":"updated","type":"autodate","onCreate":true,"onUpdate":true} ]' BOOKCASES_INDEXES='["CREATE INDEX `idx_bookcases_updated` ON `bookcases` (`updated`)"]' apply_collection "bookcases" "base" "$BOOKCASES_FIELDS" "$BOOKCASES_INDEXES" BOOKCASES_ID="$COLLECTION_ID" # --------------------------------------------------------------------------- # 2) shelves (relation -> bookcases) # --------------------------------------------------------------------------- SHELVES_FIELDS="$(jq -n --arg bcid "$BOOKCASES_ID" '[ {"name":"bookcase","type":"relation","required":true,"collectionId":$bcid,"maxSelect":1,"cascadeDelete":false}, {"name":"label","type":"text","required":true}, {"name":"position","type":"number","required":false}, {"name":"deleted","type":"bool","required":false}, {"name":"created","type":"autodate","onCreate":true,"onUpdate":false}, {"name":"updated","type":"autodate","onCreate":true,"onUpdate":true} ]')" SHELVES_INDEXES='["CREATE INDEX `idx_shelves_updated` ON `shelves` (`updated`)"]' apply_collection "shelves" "base" "$SHELVES_FIELDS" "$SHELVES_INDEXES" SHELVES_ID="$COLLECTION_ID" # --------------------------------------------------------------------------- # 3) books (relation -> shelves, relation -> users, file cover) # --------------------------------------------------------------------------- USERS_JSON="$(get_collection_json "users")" [[ -n "$USERS_JSON" ]] || fail "The built-in 'users' collection was not found — is this a fresh/corrupt PocketBase data dir?" USERS_ID="$(printf '%s' "$USERS_JSON" | jq -r '.id')" BOOKS_FIELDS="$(jq -n --arg shid "$SHELVES_ID" --arg usid "$USERS_ID" '[ {"name":"title","type":"text","required":true}, {"name":"subtitle","type":"text","required":false}, {"name":"authors","type":"json","required":false}, {"name":"isbn13","type":"text","required":false}, {"name":"isbn10","type":"text","required":false}, {"name":"publisher","type":"text","required":false}, {"name":"published_date","type":"text","required":false}, {"name":"page_count","type":"number","required":false}, {"name":"description","type":"text","required":false}, {"name":"cover","type":"file","required":false,"maxSelect":1, "mimeTypes":["image/jpeg","image/png","image/webp","image/gif"], "thumbs":["100x150","300x450"]}, {"name":"cover_source_url","type":"text","required":false}, {"name":"shelf","type":"relation","required":false,"collectionId":$shid,"maxSelect":1,"cascadeDelete":false}, {"name":"notes","type":"text","required":false}, {"name":"added_by","type":"relation","required":false,"collectionId":$usid,"maxSelect":1,"cascadeDelete":false}, {"name":"deleted","type":"bool","required":false}, {"name":"created","type":"autodate","onCreate":true,"onUpdate":false}, {"name":"updated","type":"autodate","onCreate":true,"onUpdate":true} ]')" BOOKS_INDEXES='[ "CREATE INDEX `idx_books_isbn13` ON `books` (`isbn13`)", "CREATE INDEX `idx_books_updated` ON `books` (`updated`)" ]' apply_collection "books" "base" "$BOOKS_FIELDS" "$BOOKS_INDEXES" # --------------------------------------------------------------------------- # 4) Lock down `users` — no self-registration, per SPEC. # createRule = null (superuser-only), list/view = auth-required, # update = only your own record, delete = null (superuser-only). # --------------------------------------------------------------------------- info "Locking down 'users' collection (no self-registration)..." USERS_PATCH_PAYLOAD='{ "listRule": "@request.auth.id != \"\"", "viewRule": "@request.auth.id != \"\"", "createRule": null, "updateRule": "id = @request.auth.id", "deleteRule": null }' resp="$(api_send "PATCH" "/api/collections/users" "$USERS_PATCH_PAYLOAD")" split_response UP "$resp" if [[ "$UP_CODE" != "200" ]]; then fail "Failed to lock down 'users' collection (HTTP $UP_CODE): $UP_BODY" fi ok "'users' collection locked down (createRule=null, deleteRule=null)" log "" ok "Schema setup complete." log "" log "Next: create accounts with ./create-user.sh (there is no self-registration)."