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
+14
View File
@@ -0,0 +1,14 @@
# Superuser credentials for the dev instance — never commit these.
.dev-credentials
# The PocketBase binary is platform-specific and large; fetch/build it
# per docs/SPEC.md and deploy/README.md instead of vendoring it in git.
pocketbase
pocketbase.exe
# Runtime data — the whole point of pb_migrations/ is that this is
# reproducible from scratch; the actual database/uploads are not source.
pb_data/
# Local backup artifacts produced by deploy/backup.sh.
deploy/backups/
+56
View File
@@ -0,0 +1,56 @@
## v0.40.2
- Return an error when filter params fallback fails to json serialize and optimized params replacement to execute in a single pass.
- Fixed collection index parsing error for indexes with missing name.
- Minor UI autocomplete optimizations _(prefix match, autocomplete debounce, etc.)_.
- Fixed linter warnings and comment typos.
- Bumped goja and its related dependencies _(regex unescaped dash error fix and base64 optimizations)_.
- Bumped the min Go GitHub action version to 1.27.1 as it includes some [minor `database/sql` and `enconding/json/v2` bug fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.27.1).
## v0.40.1
- Fixes for some reported regressions related to the `encoding/json/v2` update:
- allow mangling invalid UTF8 characters when serializing json data ([#7814](https://github.com/pocketbase/pocketbase/issues/7814))
- fixed OAuth2 providers config merge incorrectly replacing the entire slice ([#7815](https://github.com/pocketbase/pocketbase/issues/7815))
## v0.40.0
- Propagate console command errors and recovered panics to `app.Start()` so that the program can exit with non-zero code while still ensuring that `app.OnTerminate` hook was triggered _(responsible for the app graceful shutdown handling)_.
_⚠️ Note that this could be a slight breaking change in case you are chaining PocketBase commands and relied on the previous `0` exit status for `Command.RunE` returned errors._
_Or in other words, if you have `./pocketbase invalid && someothercommand` and previously relied that `someothercommand` will be always executed then this is no longer the case and you'll have to adjust it or replace `&&` with `;`._
- Added quotes around the default `Content-Disposition` serving filename in case custom name with special characters is provided.
- Added `Cross-Origin-Opener-Policy:same-origin` to the default security response headers.
_This is an extra precaution to prevent tab-nabbing in case custom UI plugins use `target="_blank"` without `rel="noopener"`._
- Added `Record.GetInt64(field)` helper (note that the serializable max safe integer of the `number` field is ~2^53-1).
- Added `Store.Keys()` method that returns a slice with all of the store keys.
- Added new `DELETE /api/logs` endpoint and UI control to delete all logs without changing the `maxDays` retention setting.
- Added new log settings option to limit the max `Log.Data` size that will be saved in the database (default to ~16KB).
_This is an extra precaution for the cases when logging user supplied data without validating it beforehand._
_If the resulting `Log.Data` json is above the limit, it is truncated to the last valid decoded character and an extra `"__pb_truncated__":true` log data entry will be added.`_
_Additionally, for just in case the log message is also truncated at max 8k characters._
- Added new `filesystem` low-level helper methods:
- `filesystem.NewWriter(key, opts)` to allow direct file create from an `io.Reader` value.
- `filesystem.OnNewWriter()` hook to allow listening for new/to-be-created files _(it is not exposed in `core.App` instance for now to avoid introducing breaking changes)_.
- `filesystem.OnDelete()` hook to allow listening for deleted files _(it is not exposed in `core.App` instance for now to avoid introducing breaking changes)_.
- Optimized backups to no longer transaction lock the database during backup generation ([#7799](https://github.com/pocketbase/pocketbase/discussions/7799#discussioncomment-18108244)).
- Updated `modernc.org/sqlite` to 1.57.0 and registered by default the new `_defensive=1` DSN query parameter to enable [SQLite's defensive mode](https://sqlite.org/c3ref/c_dbconfig_defensive.html#sqlitedbconfigdefensive).
- Bumped the min Go version to 1.27.0 and migrated to the new `encoding/json/v2` package.
_⚠️ Please note that Go 1.27.0 retrofitted `encoding/json` to use the v2 package under the hood but unfortunately is not fully backward compatible._
_I recommend to not push blindly an update on production and to test your PocketBase application first locally to see if everything works correctly._
+17
View File
@@ -0,0 +1,17 @@
The MIT License (MIT)
Copyright (c) 2022 - present, Gani Georgiev
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+81
View File
@@ -0,0 +1,81 @@
# Bookshelf server
PocketBase backend for the Bookshelf app — see `../docs/SPEC.md` for the
authoritative data model and API rules. This directory contains everything
needed to provision and run it.
## Layout
```
server/
pocketbase PocketBase v0.40.2 binary (gitignored — platform-specific)
pb_data/ SQLite databases + uploaded files (gitignored — runtime state)
pb_migrations/ Schema history, auto-captured — a fresh instance replays
these on first boot and ends up with the full schema
pb_hooks/ JS hook that closes a PocketBase quirk (see below)
setup-schema.sh Idempotent schema/rules provisioning (curl + jq only)
create-user.sh Superuser-driven account creation (no public sign-up)
deploy/ systemd unit, Docker, backups, remote-access guidance
```
## Quickstart (local dev)
PocketBase must already be running (see `deploy/README.md` for how to run
it as a proper service; for a quick local check you can just run the binary
directly: `./pocketbase serve`).
```sh
# 1. Provision the schema + API rules (safe to re-run).
./setup-schema.sh http://127.0.0.1:8090 <superuser-email> <superuser-password>
# 2. Create app accounts — there is no self-registration.
./create-user.sh you@example.com "a strong password" "Your Name"
```
Both scripts also read `PB_URL`/`PB_EMAIL`/`PB_PASS` from the environment,
or fall back to `.dev-credentials` (gitignored, dev-instance-only — see
`.dev-credentials` in this directory if present) if no args are given.
## Schema summary
Three collections — `bookcases`, `shelves`, `books` — each requiring
authentication for every action (list/view/create/update/delete). Deletion
in the app is always a soft `deleted` flag (tombstone), never an actual
record delete, so sync can propagate it — see `SyncEngine` in the Android
app and `docs/SPEC.md`'s Sync design section.
The built-in `users` collection is locked down: `createRule = null` means
**only a superuser can create an account** (via `create-user.sh`); regular
users can view any user (needed to resolve the `added_by` relation) and
update only their own record.
## The pb_hooks quirk
PocketBase's declarative list/search rule acts as a row-level SQL filter,
not a hard gate: an unauthenticated request against a collection whose
`listRule` requires auth still gets **`200 OK` with an empty result**,
rather than an error — because the rule can't be cleanly separated into
"deny the whole request" vs. "just don't return matching rows" (see
[pocketbase/pocketbase#6492](https://github.com/pocketbase/pocketbase/discussions/6492)).
No private data ever leaks this way, but a `200` is still the wrong signal
for "you're not allowed here." `pb_hooks/main.pb.js` adds a small
`onRecordsListRequest` hook that turns that into a `403` for the three app
collections. It's plain JS, auto-loaded by the stock `pocketbase` binary —
no recompilation, no framework — so it ships and deploys exactly like
`pb_migrations/`.
## Verifying a deployment
```sh
# Anonymous requests must be rejected (4xx) for all three collections:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/api/collections/books/records
# -> 403
# Self-registration must be rejected:
curl -s -X POST http://127.0.0.1:8090/api/collections/users/records \
-H 'Content-Type: application/json' -d '{"email":"x@x.com","password":"password123","passwordConfirm":"password123"}'
# -> 403 "Only superusers can perform this action."
```
See `deploy/README.md` for running the server long-term, reaching it from
outside your home network, and backups.
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
#
# create-user.sh — superuser-driven account creation for Bookshelf.
#
# There is no self-registration path (users.createRule = null, see
# setup-schema.sh), so the only way to create an app account is for the
# server owner to run this script as the superuser. Intended for the two
# household accounts (owner + spouse), but works for any number of users.
#
# Usage:
# ./create-user.sh <email> <password> [name]
# PB_URL=https://bookshelf.example.com ./create-user.sh alice@example.com "correct horse battery staple" Alice
#
# PB_URL, PB_EMAIL (superuser), PB_PASS (superuser) come from env, or fall
# back to ./.dev-credentials, same as setup-schema.sh.
set -u -o pipefail
NEW_EMAIL="${1:-}"
NEW_PASS="${2:-}"
NEW_NAME="${3:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CREDS_FILE="$SCRIPT_DIR/.dev-credentials"
log() { printf '%s\n' "$*" >&2; }
ok() { printf '\033[32m✓\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[31m✗ ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
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 "$NEW_EMAIL" || -z "$NEW_PASS" ]]; then
fail "Usage: $0 <email> <password> [name]"
fi
if [[ ${#NEW_PASS} -lt 8 ]]; then
fail "Password must be at least 8 characters (PocketBase minimum)."
fi
PB_URL="${PB_URL:-http://127.0.0.1:8090}"
PB_URL="${PB_URL%/}"
PB_EMAIL="${PB_EMAIL:-}"
PB_PASS="${PB_PASS:-}"
if [[ -z "$PB_EMAIL" || -z "$PB_PASS" ]]; then
if [[ -f "$CREDS_FILE" ]]; then
# 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. Set PB_EMAIL or provide $CREDS_FILE."
[[ -n "$PB_PASS" ]] || fail "No superuser password given. Set PB_PASS or provide $CREDS_FILE."
log "Creating Bookshelf account on $PB_URL"
log " Email: $NEW_EMAIL"
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)"
[[ "$AUTH_CODE" == "200" ]] || fail "Superuser login failed (HTTP $AUTH_CODE): $AUTH_BODY"
TOKEN="$(printf '%s' "$AUTH_BODY" | jq -r '.token')"
PAYLOAD="$(jq -n \
--arg email "$NEW_EMAIL" \
--arg password "$NEW_PASS" \
--arg name "$NEW_NAME" \
'{email:$email, password:$password, passwordConfirm:$password, name:$name, emailVisibility:true, verified:true}')"
CREATE_RESP="$(curl -s -w '\n%{http_code}' -X POST "$PB_URL/api/collections/users/records" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$PAYLOAD")"
CREATE_BODY="$(printf '%s' "$CREATE_RESP" | sed '$d')"
CREATE_CODE="$(printf '%s' "$CREATE_RESP" | tail -n1)"
if [[ "$CREATE_CODE" != "200" ]]; then
fail "Failed to create user (HTTP $CREATE_CODE): $CREATE_BODY"
fi
USER_ID="$(printf '%s' "$CREATE_BODY" | jq -r '.id')"
ok "Created user '$NEW_EMAIL' (id: $USER_ID)"
+30
View File
@@ -0,0 +1,30 @@
# Bookshelf PocketBase server — container image.
#
# The pocketbase binary is already vendored into ./server (this repo does not
# gitignore it out of the image build context — see server/.gitignore, which
# only excludes it from *git*). If you'd rather fetch it fresh, replace the
# COPY below with a curl of the official release for your architecture from
# https://github.com/pocketbase/pocketbase/releases (match the version
# already pinned in server/pocketbase --version, currently v0.40.2).
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /pb
COPY pocketbase /pb/pocketbase
COPY pb_migrations /pb/pb_migrations
COPY pb_hooks /pb/pb_hooks
RUN chmod +x /pb/pocketbase
# pb_data is a volume — see docker-compose.yml. Do not bake data into the
# image; it must survive container recreation.
VOLUME /pb/pb_data
EXPOSE 8090
ENTRYPOINT ["/pb/pocketbase"]
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations", "--hooksDir=/pb/pb_hooks"]
+156
View File
@@ -0,0 +1,156 @@
# Deploying Bookshelf's PocketBase server
This assumes a spare always-on machine at home (a mini PC, NUC, Raspberry Pi,
or an old laptop) running Linux. Pick **one** of the two run methods below —
systemd or Docker — not both.
## 0. Get the files onto the server
Copy the whole `server/` directory (minus `.dev-credentials`, which is
gitignored and dev-only) to the target machine, e.g.:
```sh
rsync -av --exclude .dev-credentials ~/bookshelf/server/ youruser@homeserver:/opt/bookshelf/
```
The `pocketbase` binary is architecture-specific — if your home server isn't
the same CPU architecture as wherever you built/downloaded it, grab the
matching build from https://github.com/pocketbase/pocketbase/releases
(this project is pinned to **v0.40.2**) and drop it in as `/opt/bookshelf/pocketbase`.
## 1. Run it — Option A: systemd (recommended for a bare-metal/VM host)
1. Create a dedicated unprivileged user:
```sh
sudo useradd --system --home /opt/bookshelf --shell /usr/sbin/nologin bookshelf
sudo chown -R bookshelf:bookshelf /opt/bookshelf
```
2. Install the unit:
```sh
sudo cp deploy/bookshelf.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now bookshelf
```
3. Check it's up: `systemctl status bookshelf` and `curl http://127.0.0.1:8090/api/health`.
4. Logs: `journalctl -u bookshelf -f`.
Note the unit binds PocketBase to `127.0.0.1:8090` only — it is **not**
reachable from other machines yet. That's intentional; see step 4.
## 2. Run it — Option B: Docker
```sh
cd deploy
docker compose up -d --build
docker compose logs -f
```
This also only publishes to `127.0.0.1:8090` on the host, for the same
reason. Data persists in the `bookshelf_pb_data` named volume regardless of
container restarts/rebuilds.
## 3. Provision the schema and accounts
Once the server is up and answering on `127.0.0.1:8090`, run the setup
scripts from `server/` (one directory up from here):
```sh
cd /opt/bookshelf
# If this is a brand-new PocketBase data dir, it prints a one-time setup URL
# on first launch (see `journalctl -u bookshelf` or `docker compose logs`) —
# open that in a browser first to create your superuser account.
./setup-schema.sh http://127.0.0.1:8090 <superuser-email> <superuser-password>
# Then create the household accounts — there is no public sign-up:
./create-user.sh owner@example.com "a strong password" "Owner Name"
./create-user.sh spouse@example.com "a different strong password" "Spouse Name"
```
`setup-schema.sh` is idempotent — safe to re-run any time (e.g. after
pulling an updated `server/` if the schema ever changes).
## 4. Reach it from outside your home network
The app needs a URL it can hit from anywhere your phone goes — not just your
home wifi. Two real options:
### Tailscale (recommended)
Install Tailscale on the home server and on your phone, join both to the
same tailnet. The server gets a stable `100.x.y.z` address (or a
MagicDNS name like `homeserver.your-tailnet.ts.net`) reachable from
anywhere, encrypted end-to-end, with **no ports opened on your router**.
- **Pros:** essentially zero attack surface (nothing is exposed to the
public internet at all — not even a login page), no TLS cert management,
works behind CGNAT, five-minute setup.
- **Cons:** both devices need the Tailscale app installed and signed in;
if Tailscale's coordination service has an outage, *new* connections may
be briefly unable to establish (existing ones keep working) — acceptable
for a two-person home library, not for something needing five-nines.
- Point the app's server-URL field at `http://100.x.y.z:8090` (Tailscale's
encryption makes plain HTTP tolerable *inside the tailnet*, but see the
HTTPS note below — using `https://` via Tailscale Serve, next, is easy
enough to just do).
- Even better: use [`tailscale serve`](https://tailscale.com/kb/1312/serve)
to get automatic HTTPS with a real cert on your tailnet domain, so the
app can just always use `https://`:
```sh
sudo tailscale serve --bg 8090
```
### Port-forward + Caddy (reverse proxy with TLS)
Forward a port on your router to the home server, and run
[Caddy](https://caddyserver.com/) in front of PocketBase to terminate TLS
with an automatic Let's Encrypt certificate.
- **Pros:** works with any client, no extra app/agent needed on the phone,
a real public HTTPS URL.
- **Cons:** you're now running an internet-facing service from your home —
bugs in PocketBase, Caddy, or your router's firmware are now a real
attack surface; you need a domain name (or dynamic-DNS if your ISP gives
you a changing IP) for Let's Encrypt's HTTP-01/TLS-ALPN-01 challenge to
work; residential ISPs sometimes block inbound 80/443 or use CGNAT,
which breaks this approach entirely (Tailscale sidesteps that).
Minimal Caddyfile:
```
bookshelf.yourdomain.com {
reverse_proxy 127.0.0.1:8090
}
```
Caddy handles the certificate automatically. Point your router's port
forward at the Caddy host's 443, and the app's server-URL field at
`https://bookshelf.yourdomain.com`.
### HTTPS is not optional
The app sends the account password on every login. **Never** point the app
at a plain `http://` URL that crosses the public internet or an untrusted
network — only plain HTTP over Tailscale (which is already
end-to-end-encrypted at the network layer) is acceptable, and even there,
prefer `tailscale serve` for a real cert. If you go the port-forward route,
Caddy above gets you HTTPS for free — don't skip it.
## 5. Back up regularly
```sh
./deploy/backup.sh
```
See the comments at the top of `backup.sh` for what it does (SQLite-safe
snapshot + uploaded covers, tarball, prune old ones) and the restore
procedure. Wire it into cron:
```sh
crontab -e
# add:
0 3 * * * /opt/bookshelf/deploy/backup.sh >> /var/log/bookshelf-backup.log 2>&1
```
Consider also copying the resulting tarballs off-box (another machine, a
USB drive, cloud storage) — a backup that lives on the same disk as the
data it's backing up doesn't protect against disk failure.
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
#
# backup.sh — SQLite-safe backup of a running PocketBase pb_data directory.
#
# PocketBase's data.db and auxiliary.db are SQLite databases opened in WAL
# mode. Copying the .db files directly with `cp` while the server is running
# can grab an inconsistent snapshot if a checkpoint happens mid-copy. Using
# sqlite3's `.backup` command (or PocketBase's own "backups" API) takes a
# consistent snapshot safely without stopping the server.
#
# This script uses `sqlite3 .backup`, which is the simplest option that needs
# no PocketBase superuser credentials. It backs up pb_data (including
# uploaded files) into timestamped tarballs and prunes old ones.
#
# Usage:
# ./backup.sh [PB_DATA_DIR] [BACKUP_DIR]
# PB_DATA_DIR defaults to ../pb_data (relative to this script)
# BACKUP_DIR defaults to ./backups (relative to this script)
#
# Suggested cron (nightly at 3am, keep last 14):
# 0 3 * * * /opt/bookshelf/deploy/backup.sh >> /var/log/bookshelf-backup.log 2>&1
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PB_DATA_DIR="${1:-$SCRIPT_DIR/../pb_data}"
BACKUP_DIR="${2:-$SCRIPT_DIR/backups}"
KEEP_LAST="${KEEP_LAST:-14}"
log() { printf '[%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"; }
fail() { log "ERROR: $*"; exit 1; }
command -v sqlite3 >/dev/null 2>&1 || fail "sqlite3 is required (apt install sqlite3 / apk add sqlite)."
[[ -d "$PB_DATA_DIR" ]] || fail "pb_data directory not found: $PB_DATA_DIR"
TIMESTAMP="$(date -u '+%Y%m%d_%H%M%S')"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
mkdir -p "$BACKUP_DIR"
log "Backing up pb_data from $PB_DATA_DIR"
# Consistent SQLite snapshots via the .backup command (safe for a live,
# WAL-mode database — it's the same mechanism `sqlite3 db.sqlite .backup`
# uses, which briefly locks for the copy but never corrupts).
for db in data.db auxiliary.db; do
src="$PB_DATA_DIR/$db"
if [[ -f "$src" ]]; then
log " snapshotting $db"
sqlite3 "$src" ".backup '$WORKDIR/$db'"
fi
done
# Everything else under pb_data that isn't a sqlite file/WAL/SHM (i.e.
# uploaded covers under storage/) gets copied as-is — these are immutable
# blobs once written, so a plain copy is safe.
log " copying uploaded files (storage/)"
if [[ -d "$PB_DATA_DIR/storage" ]]; then
cp -a "$PB_DATA_DIR/storage" "$WORKDIR/storage"
fi
ARCHIVE="$BACKUP_DIR/bookshelf-backup-$TIMESTAMP.tar.gz"
tar -czf "$ARCHIVE" -C "$WORKDIR" .
log "Wrote $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"
# Prune old backups, keep the most recent $KEEP_LAST.
mapfile -t old < <(ls -1t "$BACKUP_DIR"/bookshelf-backup-*.tar.gz 2>/dev/null | tail -n +$((KEEP_LAST + 1)))
if [[ ${#old[@]} -gt 0 ]]; then
log "Pruning ${#old[@]} backup(s) older than the last $KEEP_LAST"
rm -f "${old[@]}"
fi
log "Done."
# --- Restore ---------------------------------------------------------------
# 1. Stop the server: sudo systemctl stop bookshelf
# 2. Move aside the live data: mv /opt/bookshelf/pb_data /opt/bookshelf/pb_data.bak
# 3. Extract the chosen backup: mkdir /opt/bookshelf/pb_data && \
# tar -xzf bookshelf-backup-TIMESTAMP.tar.gz -C /opt/bookshelf/pb_data
# 4. Fix ownership: sudo chown -R bookshelf:bookshelf /opt/bookshelf/pb_data
# 5. Start the server again: sudo systemctl start bookshelf
# 6. Once you've confirmed it's healthy, delete pb_data.bak.
+36
View File
@@ -0,0 +1,36 @@
[Unit]
Description=Bookshelf PocketBase server
After=network-online.target
Wants=network-online.target
[Service]
# Adjust to your setup: a dedicated, unprivileged system account.
# Create it once with: sudo useradd --system --home /opt/bookshelf --shell /usr/sbin/nologin bookshelf
User=bookshelf
Group=bookshelf
# All paths below assume the whole ~/bookshelf/server tree (pocketbase binary,
# pb_data/, pb_migrations/, pb_hooks/) is deployed to /opt/bookshelf.
# Adjust WorkingDirectory/ExecStart if you deploy elsewhere.
WorkingDirectory=/opt/bookshelf
ExecStart=/opt/bookshelf/pocketbase serve \
--http=127.0.0.1:8090 \
--dir=/opt/bookshelf/pb_data \
--migrationsDir=/opt/bookshelf/pb_migrations \
--hooksDir=/opt/bookshelf/pb_hooks
Restart=always
RestartSec=5
# Hardening (safe defaults for a single-purpose service; loosen only if
# something concrete breaks).
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/bookshelf/pb_data
CapabilityBoundingSet=
AmbientCapabilities=
[Install]
WantedBy=multi-user.target
+34
View File
@@ -0,0 +1,34 @@
# Bookshelf PocketBase server — Docker Compose deployment.
#
# Build context is the parent `server/` directory (so the Dockerfile can see
# pocketbase, pb_migrations/, pb_hooks/). Run compose from server/deploy/:
#
# cd server/deploy && docker compose up -d
#
# Data lives in the named volume `bookshelf_pb_data`, independent of the
# container lifecycle — `docker compose down` (without -v) never touches it.
#
# The port is published on 127.0.0.1 only, matching bookshelf.service: this
# container is meant to sit behind Tailscale or a Caddy reverse proxy on the
# same host, never exposed directly to the internet (see deploy/README.md).
services:
pocketbase:
build:
context: ..
dockerfile: deploy/Dockerfile
container_name: bookshelf-pocketbase
restart: unless-stopped
ports:
- "127.0.0.1:8090:8090"
volumes:
- bookshelf_pb_data:/pb/pb_data
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8090/api/health"]
interval: 30s
timeout: 5s
retries: 3
volumes:
bookshelf_pb_data:
name: bookshelf_pb_data
+21
View File
@@ -0,0 +1,21 @@
/// <reference path="../pb_data/types.d.ts" />
// PocketBase's declarative API rules act as row-level filters for the
// "list"/"search" action: an unsatisfiable listRule (e.g. requiring auth)
// still returns 200 with an empty result set rather than an error, because
// the rule is just a SQL WHERE clause under the hood. See
// https://github.com/pocketbase/pocketbase/discussions/6492
//
// Bookshelf is a private, two-person library — no anonymous caller should
// ever get a 200 back from these endpoints, even an empty one, since some
// HTTP/JS clients treat "200 with []" as a successful, allowed request.
// This hook makes that explicit: anonymous list/search requests against the
// three app collections are rejected with 403, matching create/update/
// view/delete (which already 400/404 for unauthenticated callers via the
// declarative rules alone).
onRecordsListRequest((e) => {
if (!e.auth) {
throw new ForbiddenError("Authentication required.");
}
e.next();
}, "bookcases", "shelves", "books");
@@ -0,0 +1,113 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": "@request.auth.id != \"\"",
"deleteRule": "@request.auth.id != \"\"",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text3485334036",
"max": 0,
"min": 0,
"name": "note",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "number1177347317",
"max": null,
"min": null,
"name": "position",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "bool3946532403",
"name": "deleted",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_1015602688",
"indexes": [
"CREATE INDEX `idx_bookcases_updated` ON `bookcases` (`updated`)"
],
"listRule": "@request.auth.id != \"\"",
"name": "bookcases",
"system": false,
"type": "base",
"updateRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\""
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1015602688");
return app.delete(collection);
})
@@ -0,0 +1,112 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": "@request.auth.id != \"\"",
"deleteRule": "@request.auth.id != \"\"",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1015602688",
"help": "",
"hidden": false,
"id": "relation3290364264",
"maxSelect": 1,
"minSelect": 0,
"name": "bookcase",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text245846248",
"max": 0,
"min": 0,
"name": "label",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "number1177347317",
"max": null,
"min": null,
"name": "position",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "bool3946532403",
"name": "deleted",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_2495675413",
"indexes": [
"CREATE INDEX `idx_shelves_updated` ON `shelves` (`updated`)"
],
"listRule": "@request.auth.id != \"\"",
"name": "shelves",
"system": false,
"type": "base",
"updateRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\""
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2495675413");
return app.delete(collection);
})
@@ -0,0 +1,26 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("_pb_users_auth_")
// update collection data
unmarshal({
"createRule": null,
"deleteRule": null,
"listRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\""
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("_pb_users_auth_")
// update collection data
unmarshal({
"createRule": "",
"deleteRule": "id = @request.auth.id",
"listRule": "id = @request.auth.id",
"viewRule": "id = @request.auth.id"
}, collection)
return app.save(collection)
})
@@ -0,0 +1,281 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": "@request.auth.id != \"\"",
"deleteRule": "@request.auth.id != \"\"",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text724990059",
"max": 0,
"min": 0,
"name": "title",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text1367709617",
"max": 0,
"min": 0,
"name": "subtitle",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "json2383161937",
"maxSize": 0,
"name": "authors",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2325797416",
"max": 0,
"min": 0,
"name": "isbn13",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text329878418",
"max": 0,
"min": 0,
"name": "isbn10",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2632504646",
"max": 0,
"min": 0,
"name": "publisher",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2651326123",
"max": 0,
"min": 0,
"name": "published_date",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "number3814243252",
"max": null,
"min": null,
"name": "page_count",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text1843675174",
"max": 0,
"min": 0,
"name": "description",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "file2366146245",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [
"image/jpeg",
"image/png",
"image/webp",
"image/gif"
],
"name": "cover",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [
"100x150",
"300x450"
],
"type": "file"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2395634672",
"max": 0,
"min": 0,
"name": "cover_source_url",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_2495675413",
"help": "",
"hidden": false,
"id": "relation2772917219",
"maxSelect": 1,
"minSelect": 0,
"name": "shelf",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text18589324",
"max": 0,
"min": 0,
"name": "notes",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
"help": "",
"hidden": false,
"id": "relation1771793327",
"maxSelect": 1,
"minSelect": 0,
"name": "added_by",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"help": "",
"hidden": false,
"id": "bool3946532403",
"name": "deleted",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_2170393721",
"indexes": [
"CREATE INDEX `idx_books_isbn13` ON `books` (`isbn13`)",
"CREATE INDEX `idx_books_updated` ON `books` (`updated`)"
],
"listRule": "@request.auth.id != \"\"",
"name": "books",
"system": false,
"type": "base",
"updateRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\""
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2170393721");
return app.delete(collection);
})
+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)."