Architecture
Lazypock is a single Rust binary — an axum/tokio server with the compiled
Studio SPA embedded — that speaks PocketBase-canonical REST and realtime on top of PostgreSQL. Define a collection in the Studio and it becomes a real table,
exposed at /api/collections/{collection}/records, with per-action permissions, files and a realtime stream. There is no backend code to
write.
What makes it more than a REST wrapper is where the guardrails live. Permissions are Postgres row-level security policies generated from the rules, so the database is the security boundary rather than a check the application is trusted to remember. Realtime, jobs and cron all use Postgres itself as the message bus, so a node is one process with no Redis and no worker deployment.
The repo is a Cargo workspace: crates/api (the
axum router and handlers), crates/db (pool,
migrations, schema engine, RLS, query building), crates/auth, crates/realtime, crates/files, crates/hooks, crates/mail, crates/studio-embed and crates/app (the lazypock binary). studio/ is the SvelteKit admin UI, built into the
binary and served at /; official SDKs live in
their own repos and speak the same HTTP surface.
System layers
Read top to bottom — every arrow is a request or event boundary. The dashed box is the deliverable you deploy: one statically linked binary with the Studio assets inside it. Postgres is the only thing outside.
LAZYPOCK_DATABASE_URL.🗂️ Schema engine
Collection definitions in _collections / _fields become real tables, columns,
indexes and relations through the DDL engine. Each node holds the schema as an ArcSwap cache; a schema change NOTIFYs every node to reload, so the read
path stays lock-free. An existing ordinary database is reflected on boot: every table
becomes a collection and every column a field.
🛡️ Permissions as RLS
Rules are three-state: absent = superuser only, empty = public, filter = conditional.
Each rule compiles to a CREATE POLICY, and
user-facing requests run under lazypock_anon / lazypock_authenticated with claims set per
request. Policies are rebuilt on every change, so a removed rule stops applying, and
an unreadable rule denies.
🪝 Hooks engine
TypeScript hooks run in a sandboxed QuickJS context with hard limits, on a worker thread of their own, and reload on file change without a rebuild. Six record events: before/after create, update and delete. Before-hooks run before validation so a hook can supply a field; after-hooks run post-commit.
⚡ Query plane & realtime
One generic query plane serves every collection: the filter parser feeds a schema-whitelisted, fully parameterized SQL builder, and Postgres builds the JSON. Realtime fans the same change events out over SSE, RLS-filtered per subscriber.
Request lifecycle (REST CRUD)
What happens when an SDK creates or updates a record — e.g. client.collection('posts').create({title:'Hello'}):
A request arrives at the axum router
POST /api/collections/posts/records (via the SDK, JSON body) The SDK sends the request over HTTPS. If the user logged in, the EdDSA access token it received is attached as a Bearer token — an unauthenticated request is allowed too, because access is decided per collection rule, not per endpoint.
Middleware runs — every request passes through the same stack
request-log → auth → rate limit → connection id The request log records the call to _logs, with credential-shaped keys redacted (visible in Studio → Logs).
Auth verifies the token from its signature alone — no database round trip — and establishes the actor: a superuser, an auth-collection account, or nobody (public).
Rate limits are applied per route class (API, auth, per-account failures). Trusted proxy headers are honoured only from configured proxies.
The router dispatches to a handler
…/collections/:collection/records → list · create …/collections/:collection/records/:id → view · update · delete The record routes are PocketBase-canonical, so an existing PocketBase client addresses this server unchanged. The admin routes (/api/collections, /api/settings, /api/logs, /api/crons, /api/files, /api/sql) and the auth endpoints (…/auth-with-password, …/auth-refresh, …/request-password-reset) are declared alongside them.
Permissions are compiled into Postgres, not evaluated in the server
A collection rule is three-state, exactly as in PocketBase: absent means superuser-only, an empty string means public, and a filter becomes the predicate.
The filter is compiled into a CREATE POLICY and the request then runs under a restricted role — lazypock_anon or lazypock_authenticated — with its claims set per request. Postgres itself enforces the rule, so a missed check in application code cannot leak rows.
On approval, before-hooks fire. A hook can mutate the input or refuse the request outright by throwing.
The query plane builds one parameterized statement
filter, sort, fields, expand and paging are compiled by an in-house builder: identifiers are checked against the schema cache and everything else is a bound parameter. There is no string interpolation of user input into SQL.
Postgres builds the response JSON itself (json_agg / to_jsonb), so the server streams bytes rather than serializing row by row.
Write, publish, respond
After the write commits, after-hooks run, then the change is published for realtime.
_rt_log ← change row + NOTIFY lazypock_change The response — the stored record, expanded fields included — goes back to the SDK. It is read back after the write rather than via RETURNING, because Postgres applies select policies to a RETURNING row and would refuse a caller allowed to create a record but not to see it.
Realtime flow
Realtime is Server-Sent Events over a single long-lived HTTP response. Postgres is the event source: a trigger on each collection writes the change and notifies every node, so no broker sits in the middle and events survive a node restart. The same access token used for REST authenticates the stream, so rules apply consistently:
Open the event stream
GET /api/realtime (Accept: text/event-stream, Bearer token) Realtime is Server-Sent Events, not a WebSocket. The same access token used for REST authenticates the stream, so the rules apply identically to both surfaces. A client-supplied connection id lets the server exclude a subscriber from its own writes.
Subscribe to a collection
collection:posts → every change to “posts” collection:posts:abc123 → one record Subscriptions are filtered by the same rules: a subscriber only receives rows it would be allowed to read, evaluated per subscriber rather than once per node.
Every write reaches the bus the same way
trigger → _rt_log + NOTIFY → LISTEN loop → EventBus → SSE A trigger on every collection appends to _rt_log and NOTIFYs a deliberately small payload — NOTIFY caps at 8000 bytes, so ids are sent and the row is read separately. Writes from the SDKs, the Studio, hooks, cron jobs and imports all take this one path, which is why every client sees the same stream.
Subscribers receive the event
{"action":"create|update|delete","record":{…}} The originating connection is excluded, so a writer does not echo to itself. Because each event is numbered, a reconnect sends Last-Event-ID and replays what it missed from _rt_log instead of silently dropping changes.
Key design decisions
| Decision | Choice | Why |
|---|---|---|
| Language & runtime | Rust — tokio, axum, tokio-postgres | Small binary, low idle RAM, predictable latency, and no GC pause on the realtime path |
| Schema approach | Real Postgres columns, queried dynamically (no codegen) | Full Postgres power — types, indexes, JSONB, relations — with no runtime module compilation |
| Access rules | Rules compiled to Postgres RLS policies | The database enforces the rule, not the application — a forgotten check in a handler cannot leak rows |
| Tenancy | Single tenant — one database is one app | Same mental model as PocketBase’s “one file = one app”; keeps rules and auth simple. The schema is never hard-coded to public, so a shared-database mode stays open |
| Message bus | Postgres LISTEN/NOTIFY, behind an EventBus trait | No Redis to run or pay for, and events outlive a node restart. NOTIFY is capped at 8000 bytes, so payloads carry ids and the row is read separately |
| Realtime transport | SSE with numbered events and Last-Event-ID resume | One direction, proxied and buffered cleanly by ordinary HTTP infrastructure, and a reconnect recovers missed events instead of dropping them |
| Hooks | TypeScript in QuickJS, hot-reloaded, six record events | PocketBase-style extensibility without a rebuild. A sandbox with limits keeps a hook from taking the server down |
| Auth | argon2id + EdDSA access tokens, refresh sessions with rotation and replay detection, published JWKS | Tokens verify from the signature alone, with no database round trip on the hot path; a stolen refresh token is detected by its reuse |
| Images | Resized in-process by default, offloadable to a service | No sidecar to deploy in the common case; LAZYPOCK_IMAGE_SERVICE_URL moves the work out, and a build without local-image-codecs leaves the codecs out of the binary |
| Packaging | One binary — server + Studio SPA + migrations | “Download one file and run”, like PocketBase, while keeping Postgres external where a real deployment wants it |
| Roadmap | Webhooks, MFA/TOTP, and the $db/$http hook bindings | Tracked in PLAN.md. $db is a semantics decision rather than plumbing — the hook thread cannot join the request’s transaction, so it would commit independently of the record write |
Going deeper
- PLAN.md — the full architecture & development plan: the DDL engine, the rule-to-policy compiler, the hook system, the realtime design and the release pipeline.
- Repository README — feature overview and the CLI reference (
lazypock serve,migrate,superuser,healthcheck). - Crate docs — each crate carries its own module documentation:
cargo doc --workspace --open. - Module map inside the binary:
lazypock_db::schema(DDL + schema cache) ·lazypock_db::rls(policy generation) ·lazypock_hooks(QuickJS runtime and loader) ·lazypock_realtime(EventBus and fan-out) ·lazypock_files·lazypock_api::router.