Your whole backend. In one lazy pocket.
LazyPock is a PocketBase-compatible backend framework built on Elixir + Phoenix + PostgreSQL. Define collections in the Studio admin UI, get instant REST API + realtime subscriptions + file storage + auth — all with hooks, rules, and zero boilerplate.
Status: Beta. The backend (schema engine, REST API, rules, realtime, file storage, hooks, cron, auth incl. OAuth2), the Studio admin UI, and the TypeScript SDK are all complete and usable end-to-end. See What Works Now for the full breakdown.
📚 Docs: What is Lazypock · Server Guide · TypeScript SDK · all SDKs
No Elixir, Erlang, or source checkout needed — just Docker (for Postgres) and a prebuilt binary.
# 1. Start Postgres (the repo's docker-compose.yml runs Postgres 16:
# postgres/postgres@localhost:5432, database lazypock_dev)
docker compose up -d
# 2. Grab the prebuilt binary for your platform from Releases:
# https://github.com/gnuzd/lazypock/releases
# (macOS arm64 + Linux x86_64; checksums included)
# 3. Run it — the superuser is auto-created on first boot
DATABASE_URL="ecto://postgres:postgres@localhost:5432/lazypock_dev" \
SECRET_KEY_BASE="$(openssl rand -base64 48)" \
LAZYPOCK_SUPERUSER_EMAIL=admin@lazypock.app \
LAZYPOCK_SUPERUSER_PASSWORD=admin123 \
./lazypock- Server + Studio admin UI: http://localhost:4000 (login at
/_/with the superuser above) - REST API:
http://localhost:4000/api/...
Reset everything (including the database):
docker compose down -vPrefer no Docker at all? Any PostgreSQL 15+ works — just point DATABASE_URL at it. Or run from source: see Development.
import { LazypockClient } from 'lazypock';
const client = new LazypockClient({ baseUrl: 'http://localhost:4000/api' });
await client.login('admin@lazypock.app', 'admin123');
// after creating a `posts` collection in the Studio
const posts = await client.collection('posts').getList(1, 30);
client.collection('posts').subscribe((e) => console.log(e.action, e.record));Full SDK docs (install, codegen, type safety, queries, realtime, files, auth): lazypock.gnuzd.dev/sdk/typescript.
| Feature | Backend (core) | Studio (admin UI) | SDK (lazypock-ts) |
|---|---|---|---|
| 🗄️ Dynamic Collections | DDL create/drop/add field — real Postgres tables, real columns ✅ | Collection CRUD in side pane, field editor (add/remove/reorder) ✅ | — |
| 🌐 REST API | GET/POST/PATCH/DELETE /api/:collection, filter/sort/paginate ✅ |
Record browser: DataTable + dynamic RecordForm ✅ | collection(name).getList/getFullList/getOne/create/update/delete ✅ |
| 🔐 Auth | Superuser JWT + auth collection JWT (auth-with-password/auth-refresh/auth-methods) + OAuth2 (Google/GitHub/generic via Assent) ✅ |
Login page, auth guard, token persistence, auto-redirect ✅ | login/me/logout, AuthStore with pluggable storage (localStorage-backed by default) ✅ |
| 🛡️ Rules | Three-state rules (nil = superuser, "" = public, filter expr), enforced on all CRUD + manageRule ✅ |
Rule editor with lock/unlock per field ✅ | — |
| ⚡ Realtime | Phoenix Channels, rule-enforced join, anonymous allowed on public/rule-based collections ✅ | Live record updates via client.realtime.subscribe() ✅ |
RealtimeService + PocketBase-style collection(name).subscribe/unsubscribe, auto-connects without a token for public reads ✅ |
| 📁 File Storage | Upload/serve/delete, local + S3 adapters, thumbnails + on-demand scaling ✅ | Upload in record form, image picker, thumbnails in list/form ✅ | files.upload/list/delete, getFileUrl, getThumbUrl, getScaleUrl ✅ |
| 🪝 Hooks | PocketBase-style event hooks (use Lazypock.Hooks.Hook, e.next() chain, ~80 hook points), custom API routes via Router.add ✅ |
— | — |
| ⏰ Cron Jobs | Persisted _crons scheduler: 5/6-field expressions, per-job IANA timezone, SQL / HTTP-webhook / Elixir-hook actions, run-now, pg advisory-lock guarded execution ✅ |
Settings → Cron dashboard: CRUD, enable/disable, run-now, next-run preview, last-run status ✅ | — |
| 🎨 Admin Dashboard | Serves the Studio SPA at /_/*, dev proxy support ✅ |
Collections sidebar, record CRUD, field editor, rules, indexes, API keys, import/export, backups ✅ | — |
API keys can be generated from Studio Settings → API Keys. Keys are stored as a SHA-256 hash (raw value shown once at generation) and are scoped to collection listing — enough for codegen (
GET /collections) without a login round-trip. See lazypock-ts.
LazyPock/
├── core/ # Elixir backend (Phoenix app)
│ ├── lib/
│ │ ├── lazypock/ # Core library
│ │ │ ├── collections/ # Collection registry + ETS cache
│ │ │ ├── schema/ # DDL engine + type mapper
│ │ │ ├── schemas/ # GenericRecord + FilterCompiler
│ │ │ ├── rules/ # Rule enforcer (PocketBase-compatible)
│ │ │ ├── hooks/ # PocketBase-parity event hooks (Event, Registry, Router)
│ │ │ ├── auth/ # Superuser JWT auth + token
│ │ │ ├── files/ # File storage (local + S3 adapters)
│ │ │ ├── cron/ # Cron scheduler (persisted _crons)
│ │ │ └── realtime/ # Broadcaster (Phoenix PubSub)
│ │ └── lazypock_web/ # Web layer (controllers, channels)
│ │ ├── controllers/ # CollectionController, DynamicController, etc.
│ │ └── channels/ # CollectionChannel, AdminChannel
│ ├── priv/
│ │ ├── hooks/ # User hook modules (*.ex)
│ │ └── static/studio/ # Built Studio SPA
│ ├── test/
│ └── mix.exs
│
├── studio/ # SvelteKit admin UI (SPA)
│ ├── src/
│ │ ├── routes/ # SvelteKit pages
│ │ │ ├── +layout.svelte # Auth guard + realtime init
│ │ │ ├── login/ # Login page
│ │ │ └── (app)/ # Authenticated pages
│ │ │ └── collections/ # Collection CRUD + records + rules
│ │ └── lib/
│ │ ├── components/ # Reusable Svelte components
│ │ └── client.ts # LazypockClient singleton
│ └── package.json
│
├── PLAN.md # Full architecture & development plan
├── docker-compose.yml # Postgres 16 for local dev (the app is not containerized)
└── README.md
The TypeScript SDK lives in its own repository — github.com/gnuzd/lazypock-ts — and is published to npm as
lazypock. It is not part of this monorepo.
- Elixir 1.17+ + Erlang/OTP 26+
- PostgreSQL 15+ (no Postgres handy? the repo's
docker-compose.ymlstarts one — see Development) - Node.js 20+ (for Studio admin UI)
- ImageMagick 7+ (
magick/convert) — required for image thumbnails and on-demand scaling (see File Storage & Thumbnails); uploads work without it but no resizing is available zigandxz(for Burrito release builds)
Just want to click around? Skip straight to Try it in 60 seconds above — none of this is needed.
For the full walkthrough (Docker, prebuilt binary, from source, Studio, env vars, production) see the Server Guide.
cd core
export DATABASE_URL="ecto://postgres:postgres@localhost:5432/lazypock_dev"
mix setup # Install deps, create DB, run migrations, seed
mix phx.server # Starts Phoenix on http://localhost:4000No Postgres handy? docker compose up -d at the repo root starts a Postgres 16 (user/password postgres/postgres, database lazypock_dev, port 5432) that matches the DATABASE_URL above.
cd studio
npm install
npm run dev # Starts Vite dev server on http://localhost:5173The SvelteKit dev server proxies
/apirequests to the Phoenix backend (port 4000). The Studio is served athttp://localhost:5173/_/.For production, the SPA is built into
core/priv/static/studio/(gitignored — generated, not committed). Runmix assets.build(ormix assets.deploy) incore/to produce it; the release workflow does this automatically.
Consumers install the published package (npm install lazypock). Install, quick start, codegen, and API reference: lazypock.gnuzd.dev/sdk/typescript.
The backend test suite runs against a real PostgreSQL database — point
DATABASE_URL at your dev database (see Development) and run:
cd core
mix test # Full suite
mix test test/lazypock/rules/ # Access-control rule engine (enforcer + filter compiler)
mix test test/lazypock/auth/ # Auth: tokens, plug, rate limiting, OAuth2The core/test/lazypock/rules/ suite exercises the full rule pipeline end to
end against Postgres:
- Three-state rule logic —
nil= superuser-only,""= public, filter = conditional, plus the superuser bypass andmanageRuledelegation. @request.auth.*token resolution — userid/email/roleare bound as SQL parameters, never interpolated into the query text.- Typed-column casts — values are bound with explicit Postgres casts
(
$1::UUID,$1::TEXT,$1::NUMERIC) pulled fromLazypock.Schema.TypeMapper, so uuid/numeric/text comparisons work with bound parameters (see the typed column casts cases inenforcer_gaps_test.exs). - SQL injection / escaping — rule values containing single quotes or backslashes are handled safely (match exactly, never break out of the query), including a regression for multi-condition token rules (#38).
A module-by-module coverage inventory (what's tested, what's not, and by which
file) lives in TEST_COVERAGE_AUDIT.md.
Open Studio, click Setup to create the first superuser, then start creating collections — full walkthrough in the Server Guide.
cd core
MIX_ENV=prod mix release
# Binary: core/burrito_out/lazypock_macos_siliconPrebuilt binaries for macOS (arm64) and Linux (x86_64) are also published on Releases — no build toolchain needed.
Env vars, the minimal production run, and release notes: Server Guide → Production.
Two independent migration sources, both tracked in schema_migrations:
- System migrations (LazyPock's own tables —
_collections,_fields, …) ship inside the binary (priv/repo/migrations/) and are applied directly from there on boot. They are never written to a user-visible directory. - User migrations live in a user-writable directory on disk —
~/.lazypock/migrations/by default (override withLAZYPOCK_MIGRATIONS_DIR). Only your migrations belong there.
System migrations always run before user migrations. Both are applied
automatically on boot (idempotent) and are listed by lazypock migrations.
-
To add a collection after a release (no rebuild needed), drop a migration into the migrations dir that uses the collection helpers — these register the collection + fields metadata, so it shows up in the Studio and is served by the dynamic
/api/:collectionroutes:# 1. Drop a new migration file into the migrations dir cat > ~/.lazypock/migrations/20260915000000_add_posts.exs << 'EOF' defmodule Lazypock.Repo.Migrations.AddPosts do use Ecto.Migration def up do Lazypock.Migrations.create_collection("posts", type: "base", fields: [ %{"name" => "title", "type" => "text", "required" => true}, %{"name" => "published", "type" => "bool", "options" => %{"defaultValue" => false}} ] ) end def down, do: Lazypock.Migrations.drop_collection("posts") end EOF # 2. Apply it (either works) lazypock migrate # apply pending migrations, then exit # or restart the server — auto-migrate runs on boot
Other helpers:
Lazypock.Migrations.update_collection/2,Lazypock.Migrations.add_field/2,Lazypock.Migrations.drop_field/2.Raw
create tablemigrations also work — after every migrate run, any public table that isn't registered in_collectionsis automatically registered as abasecollection (columns inferred from the Postgres schema), so it shows up in the Studio and is served through/api/:collection. Foreign-key columns (references(:other)) are inferred asrelationfields pointing at the referenced table, so the Studio shows a relation dropdown and the API'sexpandresolves the related record. Ectotimestamps()tables get theirinserted_atrenamed tocreated_atand a missingupdated_atadded, so CRUD works out of the box. Internal_-prefixed tables are never touched. Use the helpers above when you want explicit control (rules, indexes, auth type). -
Check status:
lazypock migrations -
Disable auto-migrate on boot:
LAZYPOCK_AUTOMIGRATE=0 lazypock(then apply manually withlazypock migrate)
LazyPock is designed to run on top of a database you already have. Point
DATABASE_URL at any Postgres 15+ instance and its existing public tables
are auto-registered as collections on boot, so they show up in the Studio
and are served through the dynamic /api/:collection routes — no schema
conversion or import step needed:
export DATABASE_URL="ecto://user:password@db-host:5432/my_existing_db"
export SECRET_KEY_BASE="$(openssl rand -base64 48)"
LAZYPOCK_SUPERUSER_EMAIL=admin@example.com LAZYPOCK_SUPERUSER_PASSWORD=changeme \
./lazypock_macos_silicon # or the binary for your OS/archOn first boot LazyPock:
- Runs its system migrations — creates the
_-prefixed internal tables (_collections,_fields,_superusers,_request_logs, …) andschema_migrations. These are namespaced and never touch your tables. - Registers every public table that isn't already a collection as a
basecollection, inferring column types from the Postgres schema. Foreign-key columns becomerelationfields pointing at the referenced table (Studio relation dropdown + APIexpandwork out of the box). - Reconciles the LazyPock shape so CRUD works immediately: Ecto
timestamps()tables getinserted_at→created_atrenamed and a missingupdated_atadded.
Internal _-prefixed tables and schema_migrations are never registered.
Set LAZYPOCK_AUTOMIGRATE=0 to run migrations manually (lazypock migrate);
auto-registration runs as part of every migrate, so tables created later by
other tools are picked up on the next migrate or restart. Log in with the
superuser created on first boot (Studio Setup flow or the
LAZYPOCK_SUPERUSER_EMAIL/LAZYPOCK_SUPERUSER_PASSWORD env vars above).
Hooks live in a user-writable directory — ~/.lazypock/hooks/ (override with
LAZYPOCK_HOOKS_DIR). They are Elixir .ex files that use Lazypock.Hooks.Hook,
compiled at runtime on boot and registered alongside the built-in hooks.
# ~/.lazypock/hooks/posts_hooks.ex
cat > ~/.lazypock/hooks/posts_hooks.ex << 'EOF'
defmodule PostsHooks do
use Lazypock.Hooks.Hook, collection: "posts"
# PocketBase: onRecordCreate((e) => { e.record.slug = ...; e.next() })
def on_record_create(e) do
slug = e.record["title"] |> to_string() |> String.downcase()
e = Lazypock.Hooks.Event.put(e, :record, Map.put(e.record, "slug", slug))
Lazypock.Hooks.Event.next(e)
end
end
EOF
# restart the server to pick it up (an example post_hooks.ex is copied on first boot)A seed file lives at ~/.lazypock/seeds.exs (override with LAZYPOCK_SEEDS_FILE).
On first boot the bundled priv/repo/seeds.exs is copied there and run once after
migrations (tracked in _seeds_run). Edit it and re-run anytime:
lazypock seed # run the seed file (the CLI re-runs it)
lazypock seed --force # explicit re-run (same behavior)Boot-time seeding (on first boot) is idempotent — tracked in
_seeds_run— while the standalonelazypock seedcommand always re-runs the file.
Two paths, depending on what you're moving:
The Studio admin UI can export and re-import a JSON manifest of your collections (id/name/type) — the quick way to replicate a collection set across LazyPock environments or keep it in git:
- Export — Settings → Export Collections: select collections and download
lazypock-schema-<date>.json(or copy the JSON to clipboard). - Import — Settings → Import Collections: paste the JSON or upload the file; the page diffs it against your current set (added/removed/changed) and can Delete missing collections so the target matches the source exactly.
For the full detail — fields, rules, options, hooks, and records — use
Backups: Settings → Backups downloads a full JSON backup
(GET /api/export) and restores it from the same page (POST /api/import).
Records are restored upserted by id, so relations stay intact and
re-restoring the same file never duplicates data.
Both flows are also available from the CLI (run with the release binary):
lazypock backup # write lazypock-backup-<date>.json
lazypock backup /path/to/backup.json
lazypock restore /path/to/backup.json # restore schema + dataComing from a running PocketBase app? LazyPock ships a one-shot importer that
reads a PocketBase pb_data directory (SQLite data.db + storage/) and
recreates everything in Postgres:
cd core
mix lazypock.import_pocketbase --pb-dir=/path/to/pb_data
# or point directly at the database and storage dirs:
mix lazypock.import_pocketbase --pb-db=/path/to/pb_data/data.db \
--storage-dir=/path/to/pb_data/storageWhat gets migrated:
| What | Details |
|---|---|
| Collections | schema, rules (list/view/create/update/delete/manage), options, custom indexes |
| Records | values + original created/updated timestamps |
| Relations | PocketBase record ids are rewritten to deterministic UUIDv5 ids so cross-collection references stay intact; --id-map-file writes the old→new mapping |
| Auth collections | email, bcrypt password hash, verified, emailVisibility — users log in with their existing passwords |
| OAuth links | _externalAuths → _external_auths (Google/GitHub/generic) |
| Files | copied from pb_data/storage/ into LazyPock's storage |
Requires the sqlite3 CLI on PATH (used read-only). Dry-run first to preview:
mix lazypock.import_pocketbase --pb-dir=/path/to/pb_data --dry-runOptions: --pb-dir (default pb_data), --pb-db, --storage-dir,
--dry-run, --yes (skip the prompt; also imports records into existing
collections), --id-map-file (default pocketbase_id_map.json).
Uploaded files are stored on disk under core/priv/uploads/YYYY/MM/DD/{uuid}.{ext}
(date-based directories). The _files table records metadata (filename, mime type,
size, storage backend, and the collection/record/field the file belongs to).
When a file or multi_file field has Thumb sizes configured in the
collection editor (e.g. 50x50, 480x720), LazyPock generates WebP thumbnails
server-side on upload.
- Thumbnails are stored under
priv/uploads/YYYY/MM/DD/thumbs/ - Served at
GET /api/files/:id/thumbs/:size - The upload response includes a
thumbsmap:{"50x50": "/api/files/<id>/thumbs/50x50"} - The TypeScript SDK exposes
getThumbUrl(baseUrl, fileId, size)andFileRecord.thumbs
Dependency: all image resizing (thumbnails and on-demand scaling) requires
the ImageMagick CLI (magick/convert). Install it on the server:
# macOS
brew install imagemagick
# Debian/Ubuntu
sudo apt install imagemagickIf ImageMagick is not installed, uploads still work — the file is stored
normally, but no thumbnails/scaling are available and a one-time warning is
logged. Set LAZYPOCK_THUMBNAILS=0 to disable image resizing entirely (and
silence the warning).
Any uploaded image can be resized on request (no pre-configuration needed):
GET /api/files/:id/scale/:size
:size is an ImageMagick geometry:
| Size | Behavior |
|---|---|
100 |
width 100px, height auto (keep aspect) |
100x |
width 100px (same as 100) |
x100 |
height 100px, width auto |
100x100 |
fit within 100×100 box (no upscale) |
100x100! |
exact 100×100 (crop/stretch) |
Examples:
<img src="/api/files/<id>/scale/100x100" alt="">
<img src="/api/files/<id>/scale/400x" alt="">- Sizes are validated (max 4 digits per dimension) to prevent abuse.
- Results are cached on disk under
priv/uploads/YYYY/MM/DD/thumbs/— the first request generates, subsequent requests are served instantly. - The TypeScript SDK exposes
getScaleUrl(baseUrl, fileId, size).
Everything below works today — run it against the dev setup above. There is no aspirational syntax here; what isn't built yet is tracked in the Development Plan table.
Collections are created in the Studio admin UI (or via the API — see below), then instantly exposed as REST endpoints:
# List records (public / rule-allowed collections need no token)
curl http://localhost:4000/api/posts
# Fetch one record
curl http://localhost:4000/api/posts/abc123
# Create a collection (superuser token required)
curl -X POST http://localhost:4000/api/collections \
-H "Authorization: Bearer <superuser-jwt>" \
-H "Content-Type: application/json" \
-d '{
"name": "posts",
"fields": [
{"name": "title", "type": "text", "required": true},
{"name": "body", "type": "text"},
{"name": "published", "type": "bool"}
]
}'
# Create a record (requires auth unless the collection's createRule allows it)
curl -X POST http://localhost:4000/api/posts \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{"title": "Hello World", "body": "My first post"}'Every collection automatically gets three system fields — id (UUID),
created_at, and updated_at (TIMESTAMPTZ NOT NULL DEFAULT now(),
PocketBase-style). You don't need to (and shouldn't) add them yourself: the
write path maintains them on every insert/update.
If you're porting a PocketBase schema, you can still list them as autodate
fields in your migration/API payload — they are reconciled against the built-in
columns instead of being created twice:
Lazypock.Migrations.create_collection("posts",
type: "base",
fields: [
%{"name" => "title", "type" => "text"},
%{"name" => "created_at", "type" => "autodate", "options" => %{"onCreate" => true}},
%{"name" => "updated_at", "type" => "autodate", "options" => %{"onCreate" => true, "onUpdate" => true}}
]
)autodate is a first-class field type (the Studio shows it in the field
type picker). Arbitrary autodate fields work too — e.g. a last_seen_at
field:
options.onCreate: true→ the column is created withDEFAULT now()and stamped on insert.options.onUpdate: true→ the value is set to the current time on every update (the write path maintains it — caller-supplied values are overwritten, matching PocketBase).created_at/updated_atare reserved names: defining them with any type other thanautodateis rejected with a clear error.
Collections come in two types: base (plain records) and auth (accounts
with an email + write-only password). The built-in users collection is an
auth collection — like PocketBase, it's a normal (non-system) collection you
can rename, edit, or even delete.
The password field is write-only:
- Hidden — never returned in API responses, never shown in the Studio,
excluded from the SDK's default field projection (
hidden: true). - Optional — accounts may exist without a password (OAuth-only users,
invite flows), so the DB column is nullable (
required: false). - Hashed — the server bcrypt-hashes it before storing it.
Create a user exactly like any other record, using password as the field
name (PocketBase convention):
# POST /api/users — public by default (createRule = "")
curl -X POST http://localhost:4000/api/users \
-H "Content-Type: application/json" \
-d '{"email": "ada@example.com", "password": "correct-horse-battery"}'
# → 201 {"id": "...", "email": "ada@example.com", "collectionName": "users", ...}
# (no password / password_hash key in the response — ever)
# Password is optional:
curl -X POST http://localhost:4000/api/users \
-H "Content-Type: application/json" \
-d '{"email": "ghost@example.com"}'Login (PocketBase-compatible endpoints):
# POST /api/users/auth-with-password → { token, record }
curl -X POST http://localhost:4000/api/users/auth-with-password \
-H "Content-Type: application/json" \
-d '{"identity": "ada@example.com", "password": "correct-horse-battery"}'
# POST /api/users/auth-refresh → fresh token (Bearer header)
# GET /api/users/auth-methods → available auth methodsThe password key is aliased to the actual backing column (password_hash)
on write — sending either name works, so the TypeScript SDK below and any
PocketBase SDK behave identically.
import { LazypockClient } from "lazypock";
const client = new LazypockClient({ baseUrl: "http://localhost:4000/api" });
await client.login("admin@example.com", "password"); // superuser
const posts = await client.collection("posts").getList(1, 30);
const newPost = await client.collection("posts").create({
title: "Hello World",
body: "My first post",
});
// Auth collections — create a user (password optional + write-only)
const user = await client.collection("users").create({
email: "ada@example.com",
password: "correct-horse-battery",
});
const session = await client.authWithPassword(
"users",
"ada@example.com",
"correct-horse-battery"
);
// Realtime — callback-first, PocketBase-style
client.collection("posts").subscribe((e) => {
console.log(e.action, e.record);
});lazypock migrate # apply pending migrations, then exit
lazypock migrations # show applied/pending migration status
lazypock seed # run the seed file
lazypock backup # write a full JSON backup (schema + data) to lazypock-backup-<date>.json
lazypock backup FILE # ... or to a specific path
lazypock restore FILE # restore schema + data from a backup JSON (upsert by id)Planned (not available yet): sandboxed runtime hook evaluation and the Phase 10 release-polish items — see Development Plan.
Admin Dashboard (SvelteKit SPA)
│
┌─────┼──────┐
▼ ▼ ▼
Schema Hooks Rules ← Dynamic engine layer
│ │ │
└─────┼──────┘
▼
Generic Data Layer (Ecto)
│
┌─────┼─────┐
▼ ▼ ▼
REST Realtime Files ← API layer
│ │ │
└─────┼──────┘
▼
PostgreSQL
| PocketBase (Go + SQLite) | LazyPock (Elixir + PostgreSQL) |
|---|---|
| Single binary, single file | Single Burrito binary (mix release) |
| SQLite | PostgreSQL (transactional DDL, JSONB, real columns) |
| Go hooks (compiled) | Elixir hooks (compiled + declarative + runtime) |
| JS hooks (goja) | Sandboxed runtime hook eval — planned (Phase 10) |
| SSE for realtime | Phoenix Channels (WebSocket + long-polling fallback) |
| Admin UI (Svelte) | SvelteKit SPA (studio/) |
| Code-based migrations | Dynamic DDL — zero-code schema changes |
Elixir's advantages: Fault-tolerant (BEAM/OTP), massively concurrent (millions of connections), hot code reloads, and macros make dynamic schema generation natural.
See PLAN.md for the full architecture and development plan.
Current Status: BE core (Phases 1–7) done. Auth collections (Phase 3 — user JWT, login/refresh, rule integration) ✅. Studio SPA (Phase 8) ✅. TypeScript SDK (Phase 9 — lazypock-ts, npm: lazypock) ✅ — published on npm, codegen CLI included. Release setup (Phase 10 — Burrito single binary + GitHub Actions release workflow) done; polish items pending.
| Phase | What | Status |
|---|---|---|
| 1 | Foundation (DDL engine, meta tables, registry) | ✅ Complete |
| 2 | Dynamic CRUD API | ✅ Complete |
| 3 | Auth System (superusers + auth collection JWT, login/refresh, rule integration) | ✅ Complete |
| 4 | Rule Engine (Enforcer, three-state, manageRule) | ✅ Complete |
| 5 | Realtime (Phoenix Channels, Broadcaster) | ✅ Complete |
| 6 | File Storage (upload, serve, local + S3) | ✅ Complete |
| 7 | Hook System (PocketBase-parity event hooks: Event + e.next() chain, Router for custom API routes) |
✅ Complete |
| 8 | Studio Admin SPA (SvelteKit) | ✅ Complete — collections, records, rules, cron, settings shipped |
| 9 | TypeScript SDK (lazypock-ts, npm: lazypock) |
✅ Complete — published on npm, codegen CLI included |
| 10 | Polish & Release | 🔧 Release setup done — Burrito single binary + GitHub Actions release workflow; polish items pending |
- Real columns — Collections become real PostgreSQL tables with real columns. Full PG power, not a JSONB-only hack.
- Single tenancy — One database = one app. Simple, matches PocketBase's mental model.
- Generic Ecto schema — One
GenericRecordmodule uses{table, module}sources to query any table. No runtime module compilation. - PocketBase API compatible — Same filter syntax, same response format, same auth flow. Drop-in replacement for PocketBase SDK users.
| Layer | Library |
|---|---|
| Language | Elixir 1.17+ |
| Framework | Phoenix 1.7+ |
| Database | Ecto + Postgrex + PostgreSQL 15+ |
| Auth | Joken + bcrypt_elixir |
| Admin UI | SvelteKit SPA (studio/) — built to core/priv/static/studio/ |
| Image Processing | ImageMagick CLI (magick/convert) — thumbnails + on-demand scaling |
| JS SDK | TypeScript — separate repo: gnuzd/lazypock-ts |
| CSS | Tailwind CSS (Studio), none (core) |
Issues and PRs are welcome. Read PLAN.md first for the architecture and current priorities, then open an issue to discuss anything non-trivial before sending a PR.
Releases are automated with release-please, so commits on main should follow Conventional Commits (fix:, feat:, BREAKING CHANGE:) so the next version is chosen correctly.
MIT © 2024-2025 Chris Nguyen (gnuzd)
Built with 🦥 by developers who'd rather be napping.