L lazypock-ts
SDK · TypeScript Backend, Studio & SDK: stable

Lazypock — TypeScript SDK

TypeScript client library for Lazypock, an open-source, PocketBase-compatible backend. This guide covers both how to run a Lazypock server and how to use the lazypock (package lazypock-ts) client in your app.

Installation

npm install lazypock

bun / pnpm / yarn work the same way.

Quick Start

import { LazypockClient } from 'lazypock';

const client = new LazypockClient({ baseUrl: 'http://localhost:4000/api' });

// Superuser login
await client.login('[email protected]', 'password');

// Or auth collection login
await client.login('[email protected]', 'password', 'users');
// Or using the explicit method:
await client.authWithPassword('users', '[email protected]', 'password');

// List records
const posts = await client.collection('posts').getList(1, 30);
// or fetch all pages:
const all = await client.collection('posts').getFullList();

// Create a record
const newPost = await client.collection('posts').create({ title: 'Hello', published: true });

// File upload
const file = await client.files.upload(fileInput.files[0]);

// Real-time subscriptions (PocketBase-style: callback-first)
client.collection('posts').subscribe((e) => console.log(e.action, e.record));

Server Setup

Lazypock is a monorepo with three parts: the core backend (Elixir + Phoenix + PostgreSQL), the Studio admin UI (SvelteKit), and the TypeScript SDK (this package, kept in a separate repo). To use lazypock-ts against a real server, you first need a running backend.

Prerequisites

  • Elixir 1.17+ and Erlang/OTP 26+
  • PostgreSQL 15+
  • Node.js 20+ (for the Studio admin UI and the SDK)
  • ImageMagick 7+ (magick/convert) — required for image thumbnails and on-demand scaling; uploads still work without it, thumbnails just won't be generated
  • zig and xz — only needed for Burrito production release builds

Option A: Docker Compose (quickest)

The fastest way to try Lazypock is a single docker compose up — no local Elixir, Erlang, or PostgreSQL install needed. Save the following as compose.yml (this spins up Postgres + Lazypock together, pulling the latest release from git and auto-creating a superuser):

# LazyPock out-of-the-box example: Postgres + LazyPock (latest release from git)
#
#   docker compose up --build
#
# - Server + Studio admin UI:  http://localhost:4000  (login at /_/)
#   (superuser is auto-created on first boot: [email protected] / admin123)
# - REST API:                  http://localhost:4000/api/...
# - Example hooks are mounted from ./hooks  →  custom API routes:
#     GET /api/hello/{name}   and   GET /api/example/time
# - Example migration in ./migrations creates the `example_notes` table
# - ImageMagick is included in the image (thumbnails / image scaling)
name: lazypock-example

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: lazypock
    ports:
      - "5432:5432"
    volumes:
      - example_postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d lazypock"]
      interval: 5s
      timeout: 5s
      retries: 10

  lazypock:
    build: .
    ports:
      - "4000:4000"
    environment:
      DATABASE_URL: ecto://postgres:postgres@db/lazypock
      # Any 64-char secret — generate one with `mix phx.gen.secret` or openssl
      SECRET_KEY_BASE: >-
        i8x7VDmk6wY43hNFE9q0pXc2RaB5uTg1sLfzHj6nQdOe8WvKyOCmM4bJcS3PAr
      PHX_HOST: localhost
      LAZYPOCK_DATA_DIR: /data/lazypock
      LAZYPOCK_SUPERUSER_EMAIL: [email protected]
      LAZYPOCK_SUPERUSER_PASSWORD: admin123
      LAZYPOCK_CORS_ORIGINS: "*"
      # Example user hooks + migrations (edit → docker compose restart lazypock)
      LAZYPOCK_HOOKS_DIR: /hooks
      LAZYPOCK_MIGRATIONS_DIR: /migrations
    volumes:
      - ./hooks:/hooks
      - ./migrations:/migrations
      - example_lazypock_data:/data/lazypock
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

volumes:
  example_postgres_data:
  example_lazypock_data:

Then just run it — the image is built from the Dockerfile in the same directory (pulls the latest Lazypock release from git and bundles ImageMagick for thumbnails):

docker compose up --build

Once it's up:

  • Server + Studio admin UI: http://localhost:4000 (login at /_/)
  • Superuser is auto-created on first boot: [email protected] / admin123
  • REST API: http://localhost:4000/api/...
  • Example hooks mounted from ./hooks add custom routes: GET /api/hello/{name} and GET /api/example/time
  • Example migration in ./migrations creates an example_notes table

Point lazypock-ts at it right away — no manual setup needed:

import { LazypockClient } from 'lazypock';

const client = new LazypockClient({ baseUrl: 'http://localhost:4000/api' });
await client.login('[email protected]', 'admin123');

To reset everything (including the database), tear the stack down and drop the named volumes:

docker compose down -v

Editing ./hooks or ./migrations? Restart just the app container to pick up changes: docker compose restart lazypock.

Option B: Download a prebuilt binary

No Docker, no Elixir toolchain — grab a prebuilt single-binary release (built with Burrito) straight from GitHub Releases and run it directly:

# Download the latest release for your platform from:
# https://github.com/gnuzd/lazypock/releases/latest

chmod +x lazypock_macos_silicon   # or the binary matching your OS/arch

export DATABASE_URL="ecto://postgres:postgres@localhost:5432/lazypock"
export SECRET_KEY_BASE="$(openssl rand -base64 48)"
export PHX_SERVER=true
[email protected] LAZYPOCK_SUPERUSER_PASSWORD=changeme \
  ./lazypock_macos_silicon

You still need a reachable PostgreSQL 15+ instance (e.g. via docker run -p 5432:5432 postgres:16-alpine). The binary handles migrations, seeding, and serving the Studio UI on its own — see Releases for available platforms and checksums.

Option C: Manual setup — 1. Run the backend (Phoenix)

git clone [email protected]:gnuzd/lazypock.git
cd lazypock/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:4000

The REST API and realtime channels are served at http://localhost:4000.

2. Run Studio (Admin UI)

cd lazypock/studio

npm install
npm run dev          # starts Vite dev server on http://localhost:5173

The SvelteKit dev server proxies /api requests to the Phoenix backend on port 4000. Studio itself is served at http://localhost:5173/_/.

3. Install the TypeScript SDK

In your own app:

npm install lazypock

Or, to build the SDK from source:

git clone [email protected]:gnuzd/lazypock-ts.git
cd lazypock-ts
npm install
npm run build

First-time setup

  1. Open Studio at http://localhost:5173/_/ (or /_ if served directly from Phoenix)
  2. You'll be redirected to the login page
  3. Click Setup to create the first superuser account
  4. Log in and start creating collections — every collection you create in Studio gets an instant REST API + realtime channel + rules, ready to call from lazypock-ts

Production release (single binary)

Lazypock ships as a single binary via Burrito:

cd core
MIX_ENV=prod mix release
# Binary: core/burrito_out/lazypock_macos_silicon

Minimal production run example:

export DATABASE_URL="ecto://postgres:postgres@localhost:5432/lazypock"
export PHX_SERVER=true
export SECRET_KEY_BASE="$(mix phx.gen.secret)"
export PHX_HOST="localhost"
[email protected] LAZYPOCK_SUPERUSER_PASSWORD=changeme \
  ./core/burrito_out/lazypock_macos_silicon

The release runs with RUNTIME_CONFIG=false, so config is baked in at build time; environment variables are still read at boot via the Elixir config provider.

Environment variables

VariableDescriptionExample
DATABASE_URLPostgreSQL connection stringecto://postgres:postgres@localhost:5432/lazypock_dev
PHX_SERVEREnable the HTTP server (set to true)true
SECRET_KEY_BASESecret for signing cookiesmix phx.gen.secret
PHX_HOSTPublic hostname (optional, defaults to example.com)localhost
PORTHTTP port (optional, defaults to 4000)4000
POOL_SIZEDB connection pool size (optional, defaults to 10)10
LAZYPOCK_SUPERUSER_EMAILAuto-create superuser on boot[email protected]
LAZYPOCK_SUPERUSER_PASSWORDAuto-create superuser on bootyour-password
LAZYPOCK_THUMBNAILSSet to 0 to disable thumbnail/scaling generation0
LAZYPOCK_MIGRATIONS_DIRDirectory for migrations (default: ~/.lazypock/migrations)/data/lazypock/migrations
LAZYPOCK_AUTOMIGRATESet to 0 to disable auto-migrate on boot (then use lazypock migrate)0
LAZYPOCK_HOOKS_DIRDirectory for user hooks (default: ~/.lazypock/hooks)/data/lazypock/hooks
LAZYPOCK_SEEDS_FILESeed file path (default: ~/.lazypock/seeds.exs)/data/lazypock/seeds.exs

Migrations and hooks live in user-writable directories on disk (~/.lazypock/migrations, ~/.lazypock/hooks) rather than inside the binary — bundled defaults are copied there on first boot and applied automatically, and you can drop in new .exs migration files or Elixir hook modules after a release without rebuilding. See lazypock migrate / lazypock migrations / lazypock seed in the lazypock README for the full CLI.

Type Safety

The SDK offers three levels of type safety — pick what fits your project.

1. Fully typed via codegen (recommended)

Connect to your API once and generate a typed client — every collection becomes an interface with the exact field types from your schema (selects become string unions, relations become record IDs, etc.).

# In your app, after installing lazypock:
npx lazypock-gen \
  --url http://localhost:4000/api \
  --email [email protected] \
  --password your-password
# writes ./lazypock.types.ts

lazypock-gen remains as a deprecated alias for backwards compatibility — the canonical command is now simply lazypock:

npx lazypock --url http://localhost:4000/api --email [email protected] --password your-password

Use an API key instead of a password (recommended). Generate one from the Studio Settings → API Keys dashboard, then:

npx lazypock --url http://localhost:4000/api --apikey lazypock_xxxxxxxx
# or via env: LAZYPOCK_URL=... LAZYPOCK_API_KEY=... npx lazypock

API keys are stored as a SHA-256 hash (raw value shown once at generation) and are scoped to collection listing — ideal for codegen (they can GET /collections without a login round-trip, and cannot read or mutate your records).

Then in your app:

import { createClient } from './lazypock.types';

const client = createClient({ baseUrl: 'http://localhost:4000/api' });
await client.login('[email protected]', 'password');

// Collection access is fully type-checked:
const post = await client.collection('posts').getOne('abc123');
// post.title — string, post.published — boolean, …

await client.collection('posts').create({ title: 'x' });      // ✓
await client.collection('posts').create({ nope: 1 });          // ✗ compile error

Dynamic collection names are fully supported. The typed client accepts any runtime string for collection(name) and still returns the typed service for known collection names. So route params and dynamic lookups work naturally:

function load(name: string) {
  return client.collection(name).getList(); // ✓ works for any string
}

2. Hand-written generics (no codegen)

Pass a record interface to collection<T>() or use .typed<T>():

interface Post {
  id: string;
  title: string;
  published: boolean;
}

const postsSvc = client.collection('posts').typed<Post>();
const post = await postsSvc.getOne('abc123'); // post.title: string

await postsSvc.create({ title: 'Hi', published: true }); // ✓
await postsSvc.create({ title: 'Hi', nope: 1 });          // ✗ compile error

3. Runtime schema types (experimental)

Fetch schemas at runtime and let the client derive field types:

const res = await fetch('http://localhost:4000/api/collections', {
  headers: { Authorization: 'Bearer ' + token },
});
const { items } = await res.json(); // CollectionSchema[]

const client = new LazypockClient({
  baseUrl: 'http://localhost:4000/api',
  types: { schemas: items },
});

const code = client.generateTypes(); // string — write to lazypock.types.ts

The codegen CLI emits a lazypockSchema snapshot next to the types, and the generated createClient() wires it in automatically — so the schema-driven behaviour below (hidden-field exclusion, query validation) works out of the box.

CLI reference

lazypock [options]

Options:
  --url <url>        API base URL (or LAZYPOCK_URL)
  --apikey <key>    API key (or LAZYPOCK_API_KEY) — recommended, no login round-trip
  --api-key <key>   Deprecated alias for --apikey
  --email <email>    Superuser email (or LAZYPOCK_EMAIL)
  --password <pw>    Superuser password (or LAZYPOCK_PASSWORD)
  --output <file>   Output file (default: lazypock.types.ts)
  --out <file>      Deprecated alias for --output
  --package <name>   Package name to import (default: lazypock)
  --skip-system      Skip system collections

You must provide credentials one of two ways (or via the matching env vars):

  1. --apikey / LAZYPOCK_API_KEY — scoped to collection listing, no login.
  2. --email + --password / matching env vars — superuser login.

Field projection (select) & query suggestions

select(...) — pick the fields you want

select() projects list/read responses to the given fields (PocketBase fields param). Field names are type-checked when the service is typed:

const t = await client.collection('posts').select('id', 'title').getList();
// GET /api/posts?fields=id,title

await client.collection('posts').select('id', 'title').getOne('abc123'); // same
  • select('*') (or no select() call) — request all visible fields; hidden fields are excluded automatically when a schema is available.
  • select() with no arguments resets back to the default.
  • select() returns a derived service — the original is untouched, so you can keep one default service and project per-request.
  • Passing an explicit fields option overrides the select() preset.

When a schema is known (via types.schemas or codegen), hidden fields are not returned by the server: every read sends fields=<visible fields> by default, and selecting an unknown field logs a warning.

filter / sort / expand — type-checked suggestions

With a typed service, the query options validate field names (and filter operators) at compile time — your editor suggests valid fields as you type:

await postsSvc.getList(1, 20, { sort: '-title' });        // ✓ suggests title/published/…
await postsSvc.getList(1, 20, { sort: '-nope' });         // ✗ compile error

await postsSvc.getList(1, 20, {
  filter: "title ~ 'x' && published = true", // ✓ field + operator checked
});
await postsSvc.getList(1, 20, { filter: 'nope = 1' });    // ✗ compile error

await postsSvc.getList(1, 20, { expand: 'author' });      // ✓ field suggested
await postsSvc.getOne('abc', { expand: 'author' });
  • filterfield op value clauses with = != ~ !~ > >= < <= operators; &&, ||, !, and parentheses are allowed after the first clause.
  • sortfield, -field (desc), +field, or comma-separated.
  • expand — comma-separated relation field names; non-relation fields warn at runtime when a schema is available.
  • The untyped client (client.collection('posts') without typed<T>()) still accepts any string — suggestions kick in once the service is typed.

API Reference

LazypockClient

The main client class.

Constructor Options

OptionTypeDefaultDescription
baseUrlstringrequiredAPI base URL (e.g. http://localhost:4000/api)
storageStorageAdaptermemoryStorageCustom storage adapter for token persistence
authStoreAuthStoreauto-createdExplicit auth store instance
realtimeRealtimeServiceauto-createdReal-time service for WebSocket subscriptions

Authentication Methods

  • login(email, password, collection?) — Login as superuser or auth collection user
  • authWithPassword(collection, identity, password, options?) — Auth collection login
  • authRefresh(collection, options?) — Refresh auth token
  • checkSuperuser() — Check if any superuser exists
  • setup(email, password) — Create initial superuser
  • logout() — Clear auth state
  • me(options?) — Get current superuser profile

Auto-Cancellation Methods

  • autoCancellation(enable) — Globally enable/disable auto-cancellation of duplicated pending requests
  • cancelRequest(requestKey) — Abort a single pending request by key (default HTTP_METHOD + path)
  • cancelAllRequests() — Abort all pending requests

Collections Service (client.collections)

PocketBase-style service for the collections themselves (admin):

  • collections.getList(params?) — Paginated list of collections
  • collections.getFullList(options?) — Fetch all collections (auto-paginates)
  • collections.getOne(id, options?) — Get collection by ID/name
  • collections.create(data, options?) — Create collection
  • collections.update(id, data, options?) — Update collection
  • collections.delete(id, options?) — Delete collection
  • collections.subscribe(cb) — Subscribe to collection create/update/delete events (returns unsubscribe fn)
  • collections.unsubscribe() — Unsubscribe from registry events

File Operations

  • files.upload(file, filename?, options?, meta?) — Upload a file
  • files.getUrl(fileId) — Get file metadata
  • files.delete(fileId, options?) — Delete a file
  • getFileUrl(baseUrl, fileId) — Construct a file URL from base URL and file ID (utility)

Realtime

  • realtime.connect(opts) — Connect to WebSocket
  • realtime.disconnect() — Disconnect
  • realtime.subscribe(topic, callback) — Low-level subscribe (topic like collection:posts)
  • realtime.unsubscribe(topic, callback?) — Low-level unsubscribe
  • collection(name).subscribe(callback, recordId?) — Subscribe to record changes; callback receives { action, record }; returns unsubscribe fn
  • collection(name).unsubscribe(recordId?) — Unsubscribe from record changes

CollectionService

Returned by client.collection(name).

  • select(...fields) — Project reads to the given fields (see Field projection); select('*') restores the all-visible default
  • getList(page, perPage, options?) — Paginated list of records (typed filter/sort/expand/fields)
  • getFullList(options?) — Fetch all records (auto-paginates)
  • getFirstListItem(filter, options?) — Fetch first record matching filter
  • getOne(id, options?) — Get record by ID
  • create(data, options?) — Create record
  • update(id, data, options?) — Update record
  • delete(id, options?) — Delete record
  • subscribe(callback, recordId?) — Subscribe to record changes (PocketBase-style)
  • unsubscribe(recordId?) — Unsubscribe
  • authWithPassword(identity, password, options?) — Login to this auth collection
  • authRefresh(options?) — Refresh token for this auth collection
  • authMethods(options?) — Get available auth methods

AuthStore

Handles token persistence and auto-refresh.

  • token — Current JWT token
  • model — Current auth model (user record or null)
  • isValid — Whether a token exists
  • isExpired — Whether the current token has expired (with 30s buffer)
  • collectionName — Name of the auth collection used for token refresh
  • set(token, model) — Update token and model
  • setCollectionName(name) — Set the auth collection name for token refresh
  • clear() — Clear all auth state
  • onChange(callback) — Listen for auth changes (returns unsubscribe function)
  • init() — Restore persisted auth from storage

Types

interface ApiRecord {
  id: string;
  collectionId: string;
  collectionName: string;
  created: string;
  updated: string;
  [key: string]: unknown;
}

interface ListResult<T> {
  page: number;
  perPage: number;
  totalItems: number;
  totalPages: number;
  items: T[];
}

interface AuthModel {
  id: string;
  [key: string]: unknown;
}

interface FileRecord {
  id: string;
  filename: string;
  mimeType: string;
  size: number;
  url: string;
  [key: string]: unknown;
}

interface RequestOptions {
  signal?: AbortSignal;
  fetch?: typeof fetch;
  headers?: Record<string, string>;
}

Auto Cancellation

The SDK auto-cancels duplicated pending requests for you (PocketBase-compatible behaviour). When a new request is issued with the same request key as a still-pending request, the previous one is aborted — only the last request executes:

// Only the last call will execute; the first two are auto-cancelled
await client.collection('posts').getList(1, 20); // cancelled
await client.collection('posts').getList(2, 20); // cancelled
await client.collection('posts').getList(3, 20); // executed

By default the request key is HTTP_METHOD + path (e.g. "GET /api/posts?page=1"), so duplicate calls with identical URLs cancel each other. Cancelled requests reject with an ApiError whose isAbort is true:

try {
  await client.collection('posts').getList(1, 20);
} catch (err) {
  if (err instanceof ApiError && err.isAbort) {
    // superseded by a newer request — safe to ignore
  }
}

Per-request control

Pass requestKey in the request options to customize the key, or disable auto-cancellation for a specific request:

await client.collection('posts').getList(1, 20, { requestKey: 'my-list' }); // cancelled
await client.collection('posts').getList(1, 20, { requestKey: 'my-list' }); // executed

await client.collection('posts').getList(1, 20, { requestKey: null });   // executed
await client.collection('posts').getList(1, 20, { requestKey: null });   // executed

Global control

// Disable auto-cancellation globally
client.autoCancellation(false);

// Manually cancel pending requests
client.cancelRequest('GET /api/posts?page=1');
client.cancelAllRequests();

Single-flight dedup (getFullList)

getFullList() (and collections.getFullList()) are single-flight: concurrent calls with the same effective options share one in-flight request instead of firing duplicates. This means the common pattern below results in one network request, and both callers resolve with the same data — no abort rejection:

const [a, b] = await Promise.all([
  client.collection('posts').getFullList(),
  client.collection('posts').getFullList(),
]);
// one GET fired; a === b

Calls with different options (e.g. different sort/filter) are still distinct requests. Multi-page fetches continue to work normally — each page request is unique (page number is part of the URL), so pages never cancel each other.

The underlying singleFlight option is also available on any request when you want to coalesce concurrent identical calls yourself:

await client.collection('posts').getList(1, 20, { singleFlight: true });

Error Handling

The SDK throws ApiError on non-2xx responses:

import { LazypockClient, ApiError } from 'lazypock';

try {
  await client.collection('posts').create({ title: 'My Post' });
} catch (err) {
  if (err instanceof ApiError) {
    console.log(err.status);    // HTTP status code
    console.log(err.message);   // Error message
    console.log(err.data);      // Full response data
  }
}

Configuration

Storage Adapter

By default, the SDK uses localStorage for token persistence. You can provide a custom adapter:

import { LazypockClient, AuthStore } from 'lazypock';

const customStorage = {
  get: async (key) => await AsyncStorage.getItem(key),
  set: async (key, value) => await AsyncStorage.setItem(key, value),
  remove: async (key) => await AsyncStorage.removeItem(key),
};

const client = new LazypockClient({
  baseUrl: 'http://localhost:4000/api',
  storage: customStorage,
});

Auto Token Refresh

The SDK automatically refreshes expired auth tokens. When a token expires, the next API call triggers a transparent refresh via the auth-refresh endpoint. No manual intervention needed.

Real-time Subscriptions

// Subscribe to all changes in a collection (PocketBase-style: callback-first)
const off = client.collection('posts').subscribe((event) => {
  console.log(event.action); // 'create' | 'update' | 'delete'
  console.log(event.record);
});

// Subscribe to a specific record only
client.collection('posts').subscribe((event) => { /* ... */ }, 'abc123');

// Unsubscribe
client.collection('posts').unsubscribe();

// ...or call the returned unsubscribe function for one-shot listeners:
off();

Anonymous / rule-based realtime

Realtime subscriptions honor your API and list rules — matching PocketBase behavior. This means non-logged-in users can subscribe to collections whose list rules are public (empty "" string) or anon-friendly (@request.auth.* filters). The SDK auto-connects the WebSocket on first use, so no token is required to receive public change events:

// Works without logging in, as long as the collection's list rule allows it
const off = client.collection('public_feed').subscribe((e) => {
  console.log(e.action, e.record);
});

License

MIT © 2024-2025 Chris Nguyen (gnuzd)