L Lazypock
Architecture · Rust + PostgreSQL one binary

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.

Clients & adminStudio — admin UIcollections · records · fieldsrules · indexes · API keys · logsserved by the server at / (SvelteKit SPA)Your app (via SDK)REST /api/* · realtime SSE /api/realtimeofficial SDKs — TS · Swift · Android · Godotauth tokens, queries, realtime, filesHTTP & API (axum + tokio)part of the Lazypock binary ↓Router — lazypock_apirecords CRUD on /api/collections/:c/records/api/collections/:c/auth-* login & refreshstatic: files · settings · logs · crons · sqlMiddleware — every request passes throughauth verifies the EdDSA token by signaturerequest log → _logs, credentials redactedconnection id (realtime origin exclusion)rate limits · trusted proxy headersDynamic enginesSchema / DDL enginecreates real Postgres tables & columnsfield types ↔ column types, indexes, FKsmetadata in _collections / _fieldsreflects existing tables on bootArcSwap cache + NOTIFY reloadHooks engine (6 record events)TypeScript modules in a QuickJS sandboxbefore/after chain, abort by throwinghot reload · thread-owned worker$db / $http / $app — not shipped yetPermissions — Postgres RLSPocketBase-style rules per actionabsent → superuser · empty → publicfilter string → CREATE POLICYruns as lazypock_anon / _authenticatedGeneric data layerQuery planeone builder over real tablesfilter · sort · expand · fields · pagingschema-whitelisted, fully parameterizedFilesvalidated uploads · presigned PUTadapter: local disk or S3 / R2presets + thumbs, resized in-processRealtimechange row in _rt_log + NOTIFYLISTEN → EventBus → SSE fan-outRLS-filtered · origin excludedPostgreSQLexternal — connect via LAZYPOCK_DATABASE_URLPostgreSQLyour data lives in real tables with real columns — no lock-inRLS policies enforce the rules — the database is the boundarymeta: _collections · _fields · _files · _logs · _sessions · _jobs · _cronstypes, indexes, relations, JSONB — full Postgres power
Each arrow is a request/event boundary. Studio and SDK apps are external clients; the Rust server + Studio assets ship as one binary (dashed box); PostgreSQL is a separate service reached over 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'}):

1

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.

2

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.

3

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.

4

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.

5

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.

6

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:

1

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.

2

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.

3

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.

4

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

DecisionChoiceWhy
Language & runtimeRust — tokio, axum, tokio-postgresSmall binary, low idle RAM, predictable latency, and no GC pause on the realtime path
Schema approachReal Postgres columns, queried dynamically (no codegen)Full Postgres power — types, indexes, JSONB, relations — with no runtime module compilation
Access rulesRules compiled to Postgres RLS policiesThe database enforces the rule, not the application — a forgotten check in a handler cannot leak rows
TenancySingle tenant — one database is one appSame 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 busPostgres LISTEN/NOTIFY, behind an EventBus traitNo 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 transportSSE with numbered events and Last-Event-ID resumeOne direction, proxied and buffered cleanly by ordinary HTTP infrastructure, and a reconnect recovers missed events instead of dropping them
HooksTypeScript in QuickJS, hot-reloaded, six record eventsPocketBase-style extensibility without a rebuild. A sandbox with limits keeps a hook from taking the server down
Authargon2id + EdDSA access tokens, refresh sessions with rotation and replay detection, published JWKSTokens verify from the signature alone, with no database round trip on the hot path; a stolen refresh token is detected by its reuse
ImagesResized in-process by default, offloadable to a serviceNo 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
PackagingOne binary — server + Studio SPA + migrations“Download one file and run”, like PocketBase, while keeping Postgres external where a real deployment wants it
RoadmapWebhooks, MFA/TOTP, and the $db/$http hook bindingsTracked 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.