Rinse Repeat Labs · Specification

FIG. 1

rPocketBase · exploded view

Seven layers, one Rust binary. The drawing starts assembled; scroll and it comes apart one layer at a time, in the order a request travels: from the admin screen at the top down to the database file at the bottom.

Isometric exploded view of rPocketBase's seven layers, with the layer you are reading lifted open
SHEET 1 OF 1
22

Admin UI

[0022]

The dashboard ships inside the binary.

The admin app is plain HTML, CSS and JavaScript, baked in at compile time with include_str!. rpocketbase serve needs no frontend build and no second server. The script files are joined in a fixed order into one scope, without a bundler.

Path
/_/, with /admin as an alias
Built
Embedded at compile time, served from memory
Scripts
script-src 'self' with no inline script
Covers
Collections, rules, API keys, backups, system health, and the API docs at /_/#/docs
20

Realtime (SSE)

[0020]

One long-lived request, fanned out in process.

A client opens a single request to the realtime endpoint and keeps it open. The first message gives it a clientId, and record changes stream in after that. The hub that fans events out lives inside the process. That keeps it simple and fast, and it also makes rPocketBase a single instance, not a cluster.

GET /api/realtime
event: PB_CONNECT
data: {"clientId": "…"}
Keep-alive
Every 15 seconds
Auth
EventSource can't set headers, so it uses a short-lived ticket: POST /api/realtime/ticket, then ?ticket=. ?token= is rejected
API keys
A key's collection allowlist applies to events too
Scale
One instance: the hub and the rate limits live in memory
18

REST API

[0018]

PocketBase-shaped, on purpose.

Every collection's records live at one predictable address. Filtering, sorting and paging are query parameters, written in the same expression language as the rules below. Files are served with short-lived tickets, so a session token never ends up in a URL.

GET /api/collections/tasks/records?filter=(status='open')&sort=-created
Records
/api/collections/{name}/records
One record
…/records/{id}. There is no /record/.
Files
/api/files/{collection}/{id}/{file}
Tickets
rpbf_<expiry>_<hmac-sha256>
Caching
API responses are never cached
Batch
POST /api/batch, up to 50 JSON record writes in one transaction
Health
/api/health reports liveness and version only; build metadata needs RPB_HEALTH_VERBOSE=1 or the superuser checklist
16

Auth

[0016]

Slow to guess, fast to check.

Passwords are hashed with Argon2id. A login for an email that doesn't exist still runs a full Argon2 check against a dummy hash, so the response time doesn't reveal which accounts exist. Sessions are signed tokens with hard limits, and machine keys never get superuser powers.

$argon2id$v=19$m=19456,t=2,p=1$…
Hash
Argon2id, 19 MiB of memory, 2 passes, 1 lane
Sessions
HS256 JWT, valid 24 hours; renewable for up to 7 days from sign-in
Identity
Email and username match without regard to case, so Jon@… signs in as jon@…. Any other identity field you configure matches exactly
Limits
20 failed logins per 15 min per email, 40 per IP, then HTTP 429
Sign-ins
Every attempt is recorded in _auth_audit — success, wrong password, unknown identity, throttled — hashed into its own chain. The password never is
API keys
rpbk_…, never superusers: the collection allowlist is checked first, then the normal rules
Compare
Constant-time for keys and token digests
First run
The installer locks after the first superuser is created
14

Rules engine

[0014]

Every rule becomes SQL, or nothing.

Each collection has list, view, create, update and delete rules, written in a PocketBase-compatible expression language. List rules compile to parameterized SQL. If a rule can't be compiled, the query becomes WHERE 1 = 0: you get an empty list, never an unfiltered one.

listRule  owner = @request.auth.id && status != "archived"
Null rule
Superusers only
Empty rule
Public
Operators
= != > >= < <= ~ !~, plus ? any-of forms. = and ?= match any value in a multi-value relation or select
Context
@request.auth.id, collectionId, collectionName, and stored fields of the signed-in record. @request.body.* and @now
Auth names
A name the login's collection does not have is refused when the rule is saved. password, tokenKey, and secrets stay unreadable
@collection
Compiles to EXISTS on a list rule. On create, update, delete, and view it matches nothing, and the editor warns. The rule still saves
Relations
The value in a rule is the related id. Naming another field, or a dotted @request.body path, warns in the editor. The rule still saves
Modifiers
:isset :changed :length :lower
Guardrail
Client filters can't reference @collection, password or tokenKey
12

Schema

[0012]

Collections are tables. Fields are columns. Views are queries.

A collection is the unit of schema. Creating one creates a SQLite table below; each field adds a column. A collection can also carry behavior packs, such as append-only, record audit, a unique key, and TTL expiry. The whole schema travels as JSON. An import applies one collection at a time: a failure names what already landed, and a listed collection is made to match the file, so a field the file omits is dropped with its data.

Kinds
Base collections, auth collections, and views
Portable
Export and import the schema as JSON
Behaviors
Append-only, record audit, unique key, TTL. Soft delete and draft/publish are admin packs that add a flag.
Indexes
A declared CREATE INDEX, unique and partial included, is created with the collection. A unique index that existing rows break is refused and not stored. A duplicate write returns 400

12aFields

A field's type decides its storage type in SQLite, what is checked on write, and what comes back on read. There are fourteen.

TypeStored asWhat it adds
textTEXTMinimum and maximum length. pattern is checked on create, update, and batch; a pattern that is not a regular expression is refused when the field is saved. A unique key on this field can fill a prefixed sequence or another generated id
numberREALMinimum and maximum value
boolINTEGERStored as 0 or 1
emailTEXTFormat checked on write
urlTEXTFormat checked on write
dateTEXTISO-8601 text, comparable with @now and the date macros
jsonTEXTAny JSON value, kept as written
fileTEXTFilenames in pb_data/storage, served through /api/files. A refused delete or replace leaves them in place; files go only after the write commits
passwordTEXTArgon2id hash. Never returned, and never usable in a client filter
autodateTEXTTimestamp written on create, on update, or both
selectTEXTA fixed list of values. maxSelect > 1 makes it multi-select
relationTEXTRecord ids in another collection. maxSelect > 1 stores a list; = and ?= match any id in it. cascadeDelete removes this row with the target. restrictDelete refuses to delete the target while this field still points at it
editorTEXTLong text, stored as written, with the same length and pattern checks as text
secretTEXTAn AES-256-GCM envelope; sealed, see 12b. Never returned as plain text
Every field
required, hidden, presentable, system, primaryKey
Per type
min / max, pattern, maxSelect, collectionId, cascadeDelete, restrictDelete, onCreate / onUpdate, secretMode

12bSecret fields

One of the fourteen types is sealed. What lands in SQLite is an AES-256-GCM envelope, not plain text. It unlocks with the server key or with a passphrase (stretched with Argon2id), and only through an explicit reveal request, which can be audited.

POST /api/collections/{name}/records/{id}/reveal
Cipher
AES-256-GCM
Keys
The server key (.vault_key) or a passphrase stretched with Argon2id
Reveal
A dedicated endpoint, with configurable audit destinations
Caution
Backups include .vault_key

12cViews

A view is a collection with a query instead of columns. Before it is created, the query has to prove it only reads: write keywords are rejected, and a column policy blocks password and tokenKey from being selected out under another name. The fields are then read back from the database rather than declared, so the view's shape always matches what the query actually returns.

viewQuery  SELECT id, owner, COUNT(*) AS runs FROM jobs GROUP BY owner
Runs where
It becomes a real SQLite view, so the query runs in layer 10 on every read
Columns
Read back from the database, not declared by hand
Rules
listRule and viewRule apply as they do anywhere else
Writes
Refused: "View collections are read-only."
Admin
The view builder previews columns and sample rows before you save
10

SQLite

[0010]

One file, written ahead.

Data lives in an embedded SQLite database in pb_data, opened in write-ahead-log (WAL) mode so reads don't wait on writes. Views are real SQLite views, checked to be read-only before they're created. Uploaded files sit next to the database in pb_data/storage.

Journal
WAL
Integrity
Foreign keys on; 5,000 ms busy timeout
Views
Read-only viewQuery, with columns read back from the database
Expiry
Collection-level TTL, plus a sweep an admin can run
Backups
A zip of the database, WAL, file storage and secrets. Treat it as secret.

10aStarting the books

The protections above mean an issued invoice or a recorded payment cannot be deleted through the API — which also means demo data entered while trying an app would count in the real books forever. records reset is the one way out: offline, with the server stopped, and recorded.

$ rpocketbase records reset --prefix app_ --except app_settings --dry-run
Scope
Only the collections named; system collections and views are refused
Deletes
Every row including locked, append-only and restrict-protected ones, in one transaction, then their stored files
Refuses
While a server holds the directory, and when a kept record requires one being reset
Records it
One reset row in the audit trail, so /api/audit/verify still passes
Numbering
Follows the rows that remain, so the next invoice is INV-1
100

One binary

[0100]

Everything above is one process.

rpocketbase serve starts all seven layers from a single Rust binary: no Node, no separate frontend, no external database. If you want TLS, put Caddy in front of it. Caddy is the one piece that runs outside the binary.

$ rpocketbase serve
rPocketBase listening on 127.0.0.1:8090
Language
Rust
Offline CLI
backup, collections, superuser, doctor, records reset, records prune, files gc, db compact, audit prune — the same binary, run against pb_data. The read-only ones work while it serves; anything that writes refuses until the server stops, and says which process holds the directory
Stopping
SIGTERM drains what is in flight, checkpoints the WAL, closes the pool and releases the directory — a stop, not a kill
Default
127.0.0.1:8090, data in pb_data/
TLS
Optional Caddy in front, outside the binary
License
MIT. A clean-room project, independent of PocketBase.