Documentation
Why tinbase
tinbase came out of lifo and RapidNative with a hard goal: run an entire dev stack — database, auth, storage, realtime — in the browser and on phones, with no VMs and no cloud behind it. The first step was cutting the memory overhead of running that backend locally; the next was making the same backend run in-process inside a browser tab.
That is why every service is a pure (Request) => Response fetch handler and the database can be pure JavaScript. Along the way it became a Docker-free, drop-in replacement for local Supabase development that covers most use cases — so it is open source for everyone.
Architecture
The official supabase-js SDK talks to a single (Request) => Response fetch handler. That handler routes to the service implementations — PostgREST-compatible REST, GoTrue-compatible auth, Storage, the Realtime Phoenix protocol, Edge Functions, and the Studio admin API — and every one of them sits on a single swappable DbEngine adapter. Swap the engine (PGlite / native / pg-mem) without changing a line above it. In Node the handler is wrapped in an HTTP + WebSocket server; in the browser you call it in-process — see running in the browser.
Getting started
tinbase is a Supabase-compatible backend in a single process. In a project with a supabase/ directory (or none — it still boots):
npx tinbase start
# API URL: http://127.0.0.1:54321
# anon key: eyJ...
# service_role key: eyJ...Migrations in supabase/migrations/*.sql and supabase/seed.sql are applied on boot, using the same file conventions and tracking table as the Supabase CLI — so they remain portable to hosted Supabase. Then point the official SDK at it:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('http://127.0.0.1:54321', ANON_KEY)CLI reference
tinbase start # boot the server (applies pending migrations first)
tinbase migrate # apply pending migrations and exit
tinbase status # list applied migrations
tinbase keys # print anon / service_role keys
tinbase gen types # emit a TypeScript Database type
tinbase db reset # wipe + re-run migrations and seed
tinbase db diff # DDL for out-of-migration schema changes
-p, --port <n> port (default 54321; or TINBASE_PORT / PORT env)
--dir <path> project dir containing supabase/ (default cwd)
--data-dir <path> data dir (default <dir>/.tinbase/db)
--jwt-secret <s> JWT secret (or TINBASE_JWT_SECRET)
--memory in-memory database (wasm engine)
--engine <e> native (default), wasm, or pgmem
--database-url <url> use an external Postgres you already run
(postgres://…; or DATABASE_URL env)Engines
One DbEngine interface, four backends. Pick with --engine (or --database-url) — nothing above the engine changes.
nativeDefault · macOS/LinuxEmbedded Postgres 17 — real Postgres, PocketBase-class footprint
- Memory
- ~59 MB at boot
- Setup
- first run downloads ~12 MB binaries (cached in
~/.cache/tinbase) - Access
- private unix socket, trust auth — never TCP
- Platform
- macOS / Linux on x64 / arm64
wasmDefault · Windows · BrowserPGlite — Postgres compiled to WebAssembly
- Setup
- none — runs anywhere Node runs, including a browser tab
- Memory
- ~575–650 MB WASM heap (does not shrink under load)
- Parity
- identical bootstrap, migrations, RLS, and realtime CDC to native
pgmemPreview · local-dev onlyUltralight pure-JS, in-memory — no WASM — via @tinbase/pg-mem, our pg-mem fork
- Runs
- a full Supabase bootstrap + real migrations unchanged (PL/pgSQL, triggers, RLS DDL, correlated subqueries, MERGE, partitioning); REST, auth, edge functions, realtime, webhooks
- Caveats
LISTEN/NOTIFYare no-ops (CDC synthesized in JS); RLS created but not enforced (superuser); no cron / pgmq- Use
- local-dev / preview — never production
--database-urlNew in 0.10Bring your own external Postgres — REST, Auth, and Storage run against a database you already run
- Connect
tinbase start --database-url postgres://…, theDATABASE_URLenv, orcreateBackend({ databaseUrl })- Auth
- TCP with SCRAM-SHA-256 (or md5)
- Shared
- idempotent bootstrap; migrations/seed stay tracked — never assumes an empty or exclusive DB
- Soon
- TLS /
sslmode(managed providers), realtime CDC without superuser, pooling
The wasm and native engines run identical bootstrap, migrations, RLS, and realtime CDC — the full suite passes on both: TINBASE_TEST_ENGINE=native npm test.
Single binary
npm run build:binary # requires bun; emits dist-bin/tinbase (~58 MB)
./tinbase start # that's the whole deploymentOne compiled executable — no Node, npm, or Docker on the target machine. It defaults to the native engine and serves REST, Auth, Storage, and Realtime WebSockets at ~49 MB of RAM at boot, ~66 MB under load.
Studio
A built-in dashboard ships at /_/, shaped like Supabase Studio (React + Radix + Tailwind). Log in with the service_role key printed at startup. See the full Studio tour with screenshots:
- Table Editor — browse tables with pagination and row counts; insert, edit, and delete rows
- SQL Editor — run SQL with result grids and Postgres error details
- Authentication — list, create, delete users, reset passwords
- Storage — create/delete buckets, upload/delete objects, toggle public access
- Database — stats overview and applied migrations
It compiles to a single self-contained HTML file, so it works inside the single binary too.
Edge Functions
supabase.functions.invoke() runs your handlers in-process. Supabase-style Deno.serve(handler) functions (with Deno.env) run unchanged, as do export default handlers. The CLI loads them from supabase/functions/<name>/index.{ts,js,mjs}, or pass them to createBackend({ functions }). Functions using only Web APIs work as-is; npm:/jsr:/URL imports still need a bundling step.
// supabase/functions/hello/index.mjs
Deno.serve(async (req) => {
const { name = 'world' } = await req.json().catch(() => ({}))
return new Response(
JSON.stringify({ message: `Hello ${name}!` }),
{ headers: { 'content-type': 'application/json' } }
)
})Webhooks, cron & queues
The automation layer works with no extension on either engine — tinbase implements it natively rather than needing pg_net/pg_cron/pgmq installed.
Database webhooksfire an HTTP request when rows change, with Supabase's exact payload (type/table/schema/record/old_record). Configure via createBackend({ webhooks }), backend.webhooks.register(), or supabase/webhooks.json.
Cron — drop-in with pg_cron's API: select cron.schedule('nightly', '0 0 * * *', 'delete from logs') (also the 'N seconds' form), cron.unschedule(...), and the cron.job / cron.job_run_details tables. Schedules match in UTC, like hosted pg_cron. An in-process scheduler runs due jobs and logs each run.
HTTP from SQL — a pg_net emulation: net.http_post / net.http_get / net.http_delete(...) enqueue a request that an in-process worker sends, recording the reply in net._http_response. So the common Supabase pattern — a cron job that calls an Edge Function, cron.schedule(..., $$ select net.http_post(...) $$) — runs unchanged.
All of the above run only while tinbase is up (no catch-up of missed runs), execute with service-role privileges (RLS bypassed), and are available on the wasm and native engines, not pg-mem.
Queues — a pgmq subset: call from SQL or the client.
await supabase.schema('pgmq').rpc('send', { queue_name: 'jobs', msg: { task: 'email' } })
const { data } = await supabase.schema('pgmq').rpc('read', { queue_name: 'jobs', vt: 30, qty: 5 })Typed clients
Generate a Supabase-shaped Database type from the live schema, the same as supabase gen types typescript:
tinbase gen types typescript > database.types.tsimport type { Database } from './database.types'
const supabase = createClient<Database>(url, anonKey) // fully typed queriesRow Level Security
Every REST and Storage request runs inside a transaction with SET LOCAL role and request.jwt.claims applied, so policies behave exactly like hosted Supabase:
create policy "own rows" on todos
for all to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());Embedding & browser
The core is a pure (Request) => Responsefetch handler. Serve it over HTTP in Node, or hand it to supabase-js as a custom fetch and run the whole backend in-process — in the browser, PGlite persists to IndexedDB/OPFS. There's a dedicated guide to running in the browser (including the lighter pure-JS pg-mem engine):
import { createBackend } from 'tinbase'
const backend = await createBackend({
// dataDir: 'idb://my-app' <- browser persistence
migrations: [{ name: '20240101000000_init', sql: '...' }],
})
const supabase = createClient('http://localhost', backend.anonKey, {
global: { fetch: (i, init) => backend.fetch(new Request(i, init)) },
})Feature completeness
Where tinbase stands against the Supabase surface, mapped from the roadmap. ✓ Yes means it's implemented and covered by the test suite against the real supabase-js; ◑ Partial and – Planned are honest about the rest.
| Database · REST (PostgREST)~95% | ||
| Select + filters (eq/neq/gt/lt/like/ilike/in/is, or/and trees) | ✓ Yes | |
| Embedded resources (to-one, to-many, m2m, nested, !inner, aliases, hints) | ✓ Yes | |
| Insert, bulk insert, upsert (merge/ignore) | ✓ Yes | |
| Update, delete | ✓ Yes | |
| count (exact/planned/estimated), single / maybeSingle | ✓ Yes | |
| Full-text search, JSON-path filters, casts | ✓ Yes | |
| order / limit / offset (top-level and per-embed) | ✓ Yes | |
| RPC (scalar, setof, void, filters on results) | ✓ Yes | |
| Spread embeds (…rel(col)) | ✓ Yes | to-one, to-many, m2m |
| Aggregates in select (count/sum/avg/…) | ✓ Yes | top-level; not yet within embeds |
| .explain(), .csv() | ✓ Yes | |
| Auth (GoTrue)~90% | ||
| Email / password sign-up + sign-in | ✓ Yes | |
| Anonymous sign-in | ✓ Yes | |
| Anonymous → permanent upgrade | ✓ Yes | keeps the same uid |
| Session refresh + rotation, getUser / updateUser / signOut | ✓ Yes | |
| OAuth (Google, GitHub, generic OIDC) + PKCE | ✓ Yes | |
| MFA / TOTP (enroll → challenge → verify, aal2) | ✓ Yes | |
| Magic link / OTP / password recovery | ✓ Yes | viewable in the local /inbox |
| Identity linking (auth.identities) | ✓ Yes | |
| Admin user CRUD (service_role) | ✓ Yes | |
| Phone / SMS auth | – Planned | planned |
| SSO / SAML | – Planned | planned |
| Realtime~90% | ||
| postgres_changes (INSERT / UPDATE / DELETE) | ✓ Yes | |
| RLS-filtered postgres_changes | ✓ Yes | DELETE re-check is limited |
| Broadcast + presence | ✓ Yes | |
| Private channels (RLS authorization via realtime.messages) | ✓ Yes | |
| Broadcast-from-database (realtime.send) | ✓ Yes | |
| Per-row DELETE RLS (WALRUS) | ◑ Partial | authenticated/service only |
| Storage~90% | ||
| Bucket CRUD, upload (raw + multipart), download | ✓ Yes | |
| Public objects, signed URLs, signed upload URLs | ✓ Yes | |
| List with folders, move / copy, remove, size/MIME limits | ✓ Yes | |
| RLS on storage.objects | ✓ Yes | |
| Resumable (TUS) uploads | ✓ Yes | |
| Image transformations (resize/quality) | ◑ Partial | served as a no-op (original returned); real resize needs a codec |
| Edge Functions~85% | ||
| supabase.functions.invoke() | ✓ Yes | |
| Deno.serve / Deno.env / export default handlers | ✓ Yes | |
| TypeScript + relative / multi-file imports (bundled) | ✓ Yes | |
| npm: / jsr: / URL imports | ✓ Yes | via esm.sh, fetched on first run |
| Function secrets (supabase/functions/.env) | ✓ Yes | |
| Automationextension-free | ||
| Database webhooks (CDC → HTTP) | ✓ Yes | Supabase webhook payload |
| Cron jobs (cron.schedule) | ✓ Yes | pg_cron API — matches in UTC |
| HTTP from SQL (net.http_post/get) | ✓ Yes | pg_net emulation |
| Queues (pgmq: send/read/pop/archive/drop/purge/list) | ✓ Yes | replaces pgmq |
| Secrets (Supabase Vault) | ✓ Yes | create_secret / decrypted_secrets |
| Migrations, CLI & Studio~80% | ||
| supabase/migrations + seed conventions | ✓ Yes | portable to hosted Supabase |
| Runs real projects unchanged (CREATE EXTENSION tolerated, CONCURRENTLY, per-file search_path) | ✓ Yes | e.g. Cap-go/capgo: 335 migrations + seed |
| db reset, db diff, db pull, inspect | ✓ Yes | |
| gen types (TypeScript Database type) | ✓ Yes | |
| Studio: table editor, SQL, auth, RLS editor, storage, logs | ✓ Yes | at /_/ |
| Studio: table/column designer UI | – Planned | planned |
| Engines & extensions | ||
| Native embedded Postgres 17 — default (macOS/Linux) | ✓ Yes | |
| PGlite (WASM Postgres) — browser-ready, default on Windows | ✓ Yes | |
| pg-mem (pure-JS, in-memory) — @tinbase/pg-mem fork | ✓ Yes | runs PL/pgSQL, triggers, RLS DDL; RLS not enforced (superuser); no cron / pgmq |
| External Postgres (--database-url) — SCRAM/md5 auth | ◑ Partial | REST/Auth/Storage; TLS + realtime CDC in progress |
| Single-file binary | ✓ Yes | |
| Common extensions (uuid-ossp, pgcrypto, citext, pg_trgm, …) | ✓ Yes | where the engine bundles them |
| pgvector (vector search) | – Planned | needs bundled extension binaries (Phase 4) |
API coverage
| Module | Coverage | Notable gaps |
|---|---|---|
| Database (postgrest-js) | ~95% | aggregates within embeds |
| Auth (auth-js) | ~80% | MFA, SSO/SAML, phone auth |
| Storage (storage-js) | ~90% | image transforms (served as no-op) |
| Realtime (realtime-js) | ~85% | per-row DELETE RLS, private channels |
| Edge Functions | ~70% | npm:/jsr: import resolution, secrets |
| Type generation | ~85% | composite-type args, multi-schema |
Roughly 80% of the supabase-js SDK surface overall — and ~90% of what a typical CRUD + auth + storage + realtime app actually calls.
Beyond the client SDK, the local platform features real projects rely on also work: type generation, RLS (enforced on REST, Storage, and realtime), database webhooks, cron, queues (pgmq), the Studio dashboard, and Supabase-CLI migration conventions with db reset / db diff.
Benchmarks
Same workload for every backend: boot with one migrated table, then 1,000 single-row inserts followed by 1,000 filtered list queries. Memory is the physical footprint of the whole process tree (vmmap) for native processes and the sum of docker stats for containers. Apple Silicon, macOS 15.
tinbase engines in colour; PocketBase and Supabase muted for context. † PocketBase is the smallest footprint, but it is SQLite behind a different API — not a drop-in for supabase-js, unlike every tinbase engine. Linear scale; Supabase local (2,291 / 1,626 MB) is a 12-container Docker stack whose bars run off the axis (torn end) so the single-process engines stay comparable. Physical footprint of the whole process tree (vmmap / docker stats), Apple Silicon · macOS 15 · bench/footprint.ts
The two axes tell different stories: pg-mem uses the most RAM under load of the tinbase engines, yet is by far the lightest to ship — a ~6.7 MB pure-JS install with no WASM and no native binary, ideal for the browser and embedded previews.
| tinbase (binary) | tinbase (native) | tinbase (pg-mem) | tinbase (wasm) | PocketBase | Supabase local | |
|---|---|---|---|---|---|---|
| Database | real Postgres 17 + RLS | real Postgres 17 + RLS | in-memory, pure-JS | real Postgres (PGlite) + RLS | SQLite | Postgres 17 |
| Memory at boot | 49 MB | 59 MB | 71 MB | ~610 MB | 15 MB | 1,441 MB |
| Memory under load | 66 MB | 100 MB | 185 MB | ~640 MB | 24 MB | 1,626 MB |
| Data on disk (1k rows) | 39 MB | 39 MB | 0 (in-memory) | 40 MB | 7 MB | 70 MB |
| Install size | 92 MB (58 MB bin + PG) | 36 MB + Node | 6.7 MB + Node | 27 MB + Node | 30 MB | 2,291 MB + Docker |
| Processes | 2 | 2 | 1 | 1 | 1 | 12 containers |
| 1,000 inserts | 0.4 s | 0.5 s | 0.8 s | 0.8 s | 0.3 s | 1.1 s |
| 1,000 filtered reads | 0.3 s | 0.4 s | 0.8 s | 0.9 s | 0.3 s | 1.0 s |
The wasm figure is essentially PGlite's WASM heap, which measures anywhere in ~575–650 MB depending on GC timing — treat it as a band, not a point. pgmem is a pure-JS in-memory engine that runs PL/pgSQL, triggers and RLS-policy DDL (migrations apply unchanged), though as a superuser so RLS isn't enforced per-request, and cron/pgmq are absent — but a ~6.7 MB install with no WASM, the lightest option for the browser.
Methodology, raw numbers, and a reproducible script live in the repo: bench/footprint.ts and bench/results.json.