Baseline: wave 1A server complete, wave 1B Android scaffold + design system green
assembleDebug, testDebugUnitTest, and recordPaparazziDebug all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bThmkmyUUdqQpy3MXFFe5
This commit is contained in:
Executable
+337
@@ -0,0 +1,337 @@
|
||||
#!/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 "<http_code>\n<body>" 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 "<code>\n<body>"
|
||||
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 "<code>\n<body>"
|
||||
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 "<body...>\n<code>" (curl's -w appends code as the final line) and
|
||||
# re-emits it as "<code>\n<body...>" 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 "<code>\n<body>" 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 "<unix_seconds>_<action>_<name>.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)."
|
||||
Reference in New Issue
Block a user