Skip to content

Latest commit

 

History

History
596 lines (456 loc) · 17.7 KB

File metadata and controls

596 lines (456 loc) · 17.7 KB
title Getting started with Basin — 15-minute walkthrough
nav_section overview
sidebar_position 1
summary Spin up Basin with Docker, create tables, add RLS policies, call the auth and REST APIs, and wire a React/Vite frontend. Every step is copy-pasteable and verified against the real system.
tags
getting-started
tutorial
auth
rls
rest
react

Getting started with Basin

This tutorial takes you from a fresh machine to a working Basin project in about 15 minutes. You will:

  1. Start Basin with a single docker run.
  2. Connect with psql and verify the round-trip.
  3. Create a schema with two tables and an RLS policy.
  4. Sign up a user and sign in to get a JWT (basin-auth).
  5. Run CRUD over psql and the REST API (basin-rest).
  6. Wire a tiny React/Vite component to query Basin from the browser.
  7. Learn the first-deployment path to basin-cloud.

Follow the steps in order — later sections use what earlier ones set up.


Prerequisites

Tool Notes
Docker (any recent version) No Rust toolchain required for the Docker path
psql Ships with most OS Postgres packages; any version works
curl For auth and REST calls
Node 20+ For the React snippet in step 6

1. Start Basin

The GHCR image will be published on the first tagged release. Until then, build the image from the repo root (takes about five minutes on the first run; subsequent builds are fast because Docker caches the Cargo layer):

git clone https://github.com/bas-in/basin.git
cd basin

# Build the image.
docker build -t basin-server .

# Run it.
docker run --rm \
  -p 5432:5432 \
  -v basin-data:/var/basin \
  --name basin \
  basin-server

Once the published image ships, replace basin-server with ghcr.io/bas-in/basin-server:latest — the flags stay the same.

Basin is ready when you see:

INFO basin_server: pgwire listener is accept-ready bind=0.0.0.0:5432

Key environment variables (all have defaults; override with -e):

Variable Default What it controls
BASIN_BIND 0.0.0.0:5432 pgwire listen address inside the container
BASIN_DATA_DIR /var/basin Data root — mount a volume here for persistence
BASIN_STORAGE_BACKEND local local (filesystem), s3, or tigris
BASIN_PROJECTS basin=* Comma-separated user=project_id pairs; * auto-generates a ULID

The -v basin-data:/var/basin flag persists data across restarts. Omit it and all data is lost when the container exits.

Port conflict? If 5432 is already in use, map to a different host port:

docker run --rm -p 5433:5432 -v basin-data:/var/basin --name basin basin-server
# Then connect on 5433 everywhere below instead of 5432.

2. Connect with psql

Open a new terminal (leave the container running):

psql -h 127.0.0.1 -p 5432 -U basin
Parameter Value Source
Host 127.0.0.1 localhost via the -p 5432:5432 mapping
Port 5432 BASIN_BIND=0.0.0.0:5432 inside the container
User basin BASIN_PROJECTS=basin=* auto-provisions this user
Password (none) No auth required in the default dev configuration

Run a quick sanity check — this is the same round-trip the smoke harness (tests/integration/scripts/docker-smoke.sh) executes:

CREATE TABLE smoke (id int, name text);
INSERT INTO smoke VALUES (1, 'hello basin');
SELECT id, name FROM smoke WHERE id = 1;

Expected output:

 id |    name
----+-------------
  1 | hello basin
(1 row)

Data lands in Vortex-compressed columnar files under the volume:

docker exec basin find /var/basin -name '*.vortex'

Drop the smoke table when you are done:

DROP TABLE smoke;

3. Create a schema and an RLS policy

Still in the same psql session. Create two tables — a users profile table and a notes table — then lock notes behind a row-level security policy so each user can only see their own rows.

-- Users table. The id column matches the JWT `sub` claim issued by
-- basin-auth (a UUID string). Populated by the app on first sign-in.
CREATE TABLE users (
  id           TEXT        NOT NULL PRIMARY KEY,
  email        TEXT        NOT NULL UNIQUE,
  display_name TEXT,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Notes table. Each note belongs to one user.
CREATE TABLE notes (
  id         UUID        NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id    TEXT        NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  title      TEXT        NOT NULL,
  body       TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Enable RLS on notes and add two policies:

-- Enable row-level security. After this, SELECT returns zero rows for
-- any role that has no matching policy — not an error, just an empty set.
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;

-- auth.uid() returns the `sub` claim from the JWT of the current request.
-- basin-rest injects this automatically from the Authorization header.

-- SELECT: users can only read their own notes.
CREATE POLICY notes_owner_select ON notes
  FOR SELECT
  USING (user_id = auth.uid());

-- INSERT: users can only create notes for themselves.
CREATE POLICY notes_owner_insert ON notes
  FOR INSERT
  WITH CHECK (user_id = auth.uid());

-- UPDATE: users can only edit their own notes.
CREATE POLICY notes_owner_update ON notes
  FOR UPDATE
  USING (user_id = auth.uid());

-- DELETE: users can only delete their own notes.
CREATE POLICY notes_owner_delete ON notes
  FOR DELETE
  USING (user_id = auth.uid());

Insert a couple of rows to have data ready for the REST step:

-- Direct inserts bypass RLS (you are the privileged pgwire user).
-- In production, app code always goes through basin-rest with a JWT.
INSERT INTO users (id, email, display_name) VALUES
  ('user_alice', 'alice@example.com', 'Alice'),
  ('user_bob',   'bob@example.com',   'Bob');

INSERT INTO notes (user_id, title, body) VALUES
  ('user_alice', 'First note',  'Hello from Alice'),
  ('user_alice', 'Second note', 'Still Alice'),
  ('user_bob',   'Bob note',    'Only Bob sees this');

Query without a JWT (privileged pgwire session bypasses RLS):

SELECT id, user_id, title FROM notes ORDER BY created_at;

All three rows are visible here because you are connected as the privileged project user via pgwire, not through basin-rest. The RLS policies fire when requests arrive over the REST API carrying a JWT.


4. Auth: sign up and sign in

basin-auth and basin-rest are enabled by default in the Docker image (they ship in the OSS bundle). They listen on the same port as pgwire and share the HTTP path prefix /auth/v1/ and /rest/v1/ respectively.

SMTP note: basin-auth requires SMTP configuration to send email verification and password-reset links. In the default Docker dev setup, email flows are disabled (BASIN_AUTH_SMTP_TLS=none) so sign-up succeeds immediately without an inbox check. For production, pass the full SMTP env block described in ADR 0005.

Sign up

curl -s -X POST http://127.0.0.1:5432/auth/v1/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"hunter2hunter2"}' \
  | jq .

Response:

{
  "access_token": "eyJ...",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "rt_...",
  "user": {
    "id": "01JW...",
    "email": "alice@example.com"
  }
}

The access_token is a short-lived JWT (default TTL: 1 hour). The refresh_token is an opaque token (default TTL: 30 days) used to rotate the access token via POST /auth/v1/refresh.

Sign in

JWT=$(curl -s -X POST http://127.0.0.1:5432/auth/v1/signin \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"hunter2hunter2"}' \
  | jq -r .access_token)

echo "JWT: ${JWT:0:40}..."

Keep $JWT in your shell. Every REST and direct-fetch call below passes it in the Authorization: Bearer $JWT header. basin-rest verifies the token and extracts auth.uid() from the sub claim, which the RLS policies use.


5. CRUD over psql and the REST API

psql path (always works today)

You can set the JWT claim in psql to simulate what basin-rest does automatically, which is useful for debugging RLS policies:

-- Set the JWT sub claim so auth.uid() resolves to alice's id.
SET request.jwt.claims = '{"sub":"user_alice"}';

-- Now the RLS policy fires: only Alice's notes are visible.
SELECT id, title FROM notes;

Expected output (only Alice's two rows):

 id                                   | title
--------------------------------------+-------------
 <uuid>                               | First note
 <uuid>                               | Second note
(2 rows)
-- Switch to Bob's claim — Bob's note is now visible instead.
SET request.jwt.claims = '{"sub":"user_bob"}';
SELECT id, title FROM notes;

SET request.jwt.claims is a development introspection tool for testing RLS policies directly in psql. In production, basin-rest injects the JWT context automatically from the Authorization header on every request.

Reset the session before continuing:

RESET request.jwt.claims;

REST API path (basin-rest)

basin-rest exposes every table at /rest/v1/<table> using PostgREST-compatible URL conventions. The $JWT from Step 4 is the authentication credential.

Read notes (RLS-filtered to Alice's rows):

curl -s "http://127.0.0.1:5432/rest/v1/notes" \
  -H "Authorization: Bearer $JWT" \
  | jq .

Because the RLS policy is active, only Alice's notes come back — there is no WHERE user_id = ? in the application code.

Filter and project:

# Only the title column, ordered newest first.
curl -s "http://127.0.0.1:5432/rest/v1/notes?select=id,title&order=created_at.desc" \
  -H "Authorization: Bearer $JWT" \
  | jq .

Insert a new note:

curl -s -X POST "http://127.0.0.1:5432/rest/v1/notes" \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  -d '{"user_id":"user_alice","title":"From REST","body":"Inserted via HTTP"}' \
  | jq .

The Prefer: return=representation header returns the inserted row, including the server-generated id and created_at.

Update a note (replace <note-id> with an id from above):

curl -s -X PATCH "http://127.0.0.1:5432/rest/v1/notes?id=eq.<note-id>" \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  -d '{"title":"Updated title"}' \
  | jq .

Delete a note:

curl -s -X DELETE "http://127.0.0.1:5432/rest/v1/notes?id=eq.<note-id>" \
  -H "Authorization: Bearer $JWT" \
  | jq .

Supported query parameters (REST v1)

Parameter Example Effect
select select=id,title Column projection
<col>=eq.<val> user_id=eq.user_alice Equality filter
<col>=gt.<val> created_at=gt.2026-01-01 Greater-than
<col>=lt.<val> created_at=lt.2027-01-01 Less-than
<col>=in.(a,b) user_id=in.(user_alice,user_bob) Membership
<col>=is.null body=is.null Null check
order order=created_at.desc Ordering
limit limit=10 Result cap
offset offset=20 Pagination offset

6. A React/Vite frontend

The examples/saas-starter/ directory in the repo is a working Vite + React application that demonstrates CRUD, auth, and RLS end-to-end.

Below is the minimal pattern from that app — a note-list component that authenticates, fetches notes (RLS-filtered server-side), and inserts new ones.

npm package status: @basin/basin-js is not yet published to npm as of May 2026. The saas-starter uses a fetch-based shim (src/lib/basin-js-stub.ts) that implements the same interface. The snippet below follows the same pattern — swap in the real SDK once it publishes, with no API changes.

Install

npm create vite@latest my-basin-app -- --template react-ts
cd my-basin-app
npm install

Copy examples/saas-starter/src/lib/basin-js-stub.ts into your project as src/lib/basin.ts and adjust the import path. Or point directly at the real SDK once it ships:

# Once published:
npm install @basin/basin-js

Configure

Create .env.local:

VITE_BASIN_URL=http://localhost:5432
VITE_BASIN_ANON_KEY=     # leave empty in dev (no RLS on anon key needed)

basin client singleton (src/lib/basin.ts)

// Until @basin/basin-js is published, alias basin-js-stub.ts here.
// The API surface is identical — swap the import once the package ships.
import { createClient } from './basin-js-stub'

const BASIN_URL  = import.meta.env.VITE_BASIN_URL  ?? 'http://localhost:5432'
const BASIN_ANON = import.meta.env.VITE_BASIN_ANON_KEY ?? ''

export const basin = createClient(BASIN_URL, BASIN_ANON)

Auth (sign in, sign up)

// Sign up — POST /auth/v1/signup
const { data, error } = await basin.auth.signUp({ email, password })
// data.user.id  = JWT `sub` claim (matches users.id in your schema)
// data.session.accessToken = JWT stored in localStorage automatically

// Sign in — POST /auth/v1/signin
const { data, error } = await basin.auth.signInWithPassword({ email, password })

// Sign out — POST /auth/v1/signout
await basin.auth.signOut()

The JWT is stored in localStorage under the key basin-session and attached automatically to every subsequent REST call.

Notes component — fetch + insert + delete

import { useEffect, useState } from 'react'
import { basin } from './lib/basin'

interface Note {
  id: string
  user_id: string
  title: string
  body: string | null
  created_at: string
}

export function NoteList({ userId }: { userId: string }) {
  const [notes, setNotes]     = useState<Note[]>([])
  const [newTitle, setNewTitle] = useState('')

  // Fetch — RLS enforced server-side; no WHERE clause needed here.
  useEffect(() => {
    basin
      .from<Note>('notes')
      .select('id, user_id, title, body, created_at')
      .then(({ data, error }) => {
        if (!error && data) setNotes(data)
      })
  }, [])

  // Insert
  async function addNote() {
    if (!newTitle.trim()) return
    const { data, error } = await basin
      .from<Note>('notes')
      .insert({ user_id: userId, title: newTitle.trim() })
    if (!error && data?.[0]) {
      setNotes(n => [...n, data[0]])
      setNewTitle('')
    }
  }

  // Delete
  async function deleteNote(id: string) {
    const { error } = await basin.from<Note>('notes').delete().eq('id', id)
    if (!error) setNotes(n => n.filter(note => note.id !== id))
  }

  return (
    <div>
      <ul>
        {notes.map(note => (
          <li key={note.id}>
            <strong>{note.title}</strong>
            <button onClick={() => deleteNote(note.id)}>Delete</button>
          </li>
        ))}
      </ul>
      <input
        value={newTitle}
        onChange={e => setNewTitle(e.target.value)}
        placeholder="New note title"
      />
      <button onClick={addNote}>Add</button>
    </div>
  )
}

Under the hood each call translates to:

Operation HTTP call
.from('notes').select(...) GET /rest/v1/notes?select=id,user_id,...
.insert({...}) POST /rest/v1/notes with Prefer: return=representation
.delete().eq('id', id) DELETE /rest/v1/notes?id=eq.<id>

The Authorization: Bearer <jwt> header is attached automatically from the session stored in localStorage. The RLS policies fire on the server; the component never writes a WHERE user_id = ? clause.

For a full working example with org switcher, avatar upload, and Drizzle migrations, see examples/saas-starter/.


7. First deployment to basin-cloud

Forward-spec note: basin-cloud and basin-cli are not yet publicly available. This section describes the intended path; the self-hosted Docker setup above is the working path today.

When basin-cloud launches, the deployment flow will be:

# Install basin-cli (Go binary, Sigstore-signed release artefacts).
# The bas-in/basin-cli repository will publish pre-built binaries.
basin login                            # OAuth/JWT flow, stores credential in OS keychain
basin projects create my-app           # provisions a Basin engine on Fly Machines
basin projects connect my-app          # prints the postgres:// URL for psql / .env

Point your app at the cloud engine URL — everything else (psql, REST, basin-js) works identically because the wire protocol is the same.

For production self-hosting today (before basin-cloud launches), see docs/deployment.md for the full env-var reference, S3/Tigris object storage configuration, and durable catalog setup with BASIN_CATALOG=postgres://....


Stop and clean up

# Stop the container (the named volume basin-data is preserved).
docker stop basin

# Remove the volume too — all data is discarded.
docker volume rm basin-data

Next steps

Document What it covers
5-Minute Docker Quickstart Single-command start, env-var reference, troubleshooting
SQL compatibility Which Postgres SQL Basin accepts and which it defers
Multi-project SaaS Per-project isolation, RLS with auth.uid(), cost math at 10k projects
HTAP guide Hot-tier vs cold-tier performance, basin.sort_by, memtable caps
Deployment Production storage backends, topology, configuration
CAPABILITIES.md Full capability matrix: shipped, in-progress, planned, off-roadmap
examples/saas-starter/ Full React/Vite app with auth, RLS, Drizzle migrations, and avatar upload