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:
2026-09-06 01:58:37 +00:00
commit 6c17e42037
79 changed files with 3773 additions and 0 deletions
+337
View File
@@ -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)."