Documentation · field guide

Basin, from first run to production

Basin is a bucket-native, multi-project, Postgres-compatible database. One Rust binary: pgwire on the front, Vortex-compressed columnar files on any S3-compatible bucket on the back, a file-backed WAL in between. A project is a bucket prefix rather than a provisioned database, so operator cost is O(bytes active) rather than O(projects provisioned).

01 · orientation

What Basin is

Basin speaks the PostgreSQL wire protocol, so psql, tokio-postgres, asyncpg, JDBC, Diesel and SeaORM connect to it without a shim. Underneath it is not Postgres at all: tables are columnar files in an object store, described by an Iceberg-style catalog, with a write-ahead log in front for durability and an in-memory hot tier for recent writes.

Two properties are structurally true regardless of workload, and they are the reason the project exists. Both are measured, and both are published with their losses alongside.

~310 KiB
RAM per held-open connection · Postgres ~8.1 MiB
~2 KiB
RAM per idle project · 2.16 KiB over 1,000 projects
1,000 / 0
Connections held / refused under a 1,000-conn flood
  • A pure-Rust async server does not fork a backend per connection. Basin holds 1,000 connections where Postgres holds 100 and refuses 900. That is not a tuning result; it is the difference between a tokio server and a forking daemon.
  • A new project is a bucket prefix, not a provisioned database. Idle projects cost their bytes and nothing else. Ten thousand projects is the same architecture and the same binary as one.

Everything else in the binary — native vector search, auth, a PostgREST-shaped API, realtime, blob storage, Wasm functions, pg_cron/pg_net/pg_trgm/PostGIS subsets as native crates — exists to make those two wins reachable from real applications rather than from a benchmark harness.

02 · honesty

Status & scope

Basin is pre-alpha and built in the open. The workspace version is 0.1.9, which is also the newest release tag; a substantial amount of unreleased work sits on top of it on main. Use it today to evaluate the cost economics, to prototype multi-project patterns, or to contribute — not to hold data you cannot lose.

Read this before you trust a doc

The repository's design documents describe intent as well as shipped behaviour, and in a few places they have drifted. docs/architecture.md still calls Parquet the canonical format and describes Raft as the durability boundary; neither matches the default build. Several operator pages reference a Prometheus /metrics endpoint that does not exist in this repository. Where this page and a repository doc disagree, this page has been checked against the source.

The licence split matters if you are vendoring: the engine, services and CLI are Apache-2.0; all ten client SDKs are MIT. Every crate in the workspace carries #![forbid(unsafe_code)].

03 · start here

Install & run

Basin is one binary and one data directory. Local development needs no external object store and no external database — the same binary that runs on your laptop is the one that runs against S3, with different environment variables.

Docker

The image needs no Rust toolchain. The first build takes a few minutes because the Cargo dependency layer is cold; subsequent builds are cached.

# Build the image from the repo root.
docker build -t basin-server .

# Run it. pgwire on :5432, data persisted in a named volume.
docker run --rm \
  -p 5432:5432 \
  -v basin-data:/var/basin \
  --name basin \
  basin-server

You are ready when the log prints INFO basin_server: pgwire listener is accept-ready bind=0.0.0.0:5432. The image ships a healthcheck (nc -z 127.0.0.1 5432) on a ten-second interval. The release workflow publishes ghcr.io/vul-os/basin-server on every v* tag.

Port already taken

Postgres probably owns 5432 on your machine. Remap the host side and leave the container alone: docker run --rm -p 5433:5432 -v basin-data:/var/basin --name basin basin-server.

From source

Requires the toolchain pinned in rust-toolchain.toml. The default bind from source is 127.0.0.1:5433 — deliberately not 5432, so a local Postgres and a local Basin can run side by side.

BASIN_DATA_DIR=/tmp/basin cargo run -p basin-server

That gives you pgwire on 127.0.0.1:5433, a durable WAL plus Vortex columnar files under /tmp/basin/, and a volatile in-memory catalog — fast to iterate against, and gone on restart. Point BASIN_CATALOG at object_store or a Postgres DSN the moment you want metadata to survive a restart. See Configuration.

Build and test the workspace

cargo build --workspace
cargo test  --workspace
04 · the wire

Connect

Any PostgreSQL client works. The username selects the project — Basin parses the project out of the username at connection accept, so there is no per-query tenant argument and no global lookup table on the hot path.

# Against the Docker container (default user `basin`)
psql -h 127.0.0.1 -p 5432 -U basin

# Against a from-source server started with BASIN_PROJECTS='alice=*,bob=*'
psql -h 127.0.0.1 -p 5433 -U alice

Declaring projects

BASIN_PROJECTS takes comma-separated user=project_id pairs, where * asks Basin to allocate a ULID. The image defaults to basin=*.

docker run --rm \
  -p 5432:5432 \
  -v basin-data:/var/basin \
  -e BASIN_PROJECTS="alice=*,bob=*" \
  basin-server

Provisioned credentials

With basin-auth enabled, POST /admin/v1/projects mints a project and returns a connection URL of the form postgres://<user>:<password>@host:5433/<db>. The username is the project ULID plus a short suffix, so the project identity is carried by the credential itself. Rotation is POST /admin/v1/projects/{user}/rotate, and the old password stops working immediately. A bad password fails with SQLSTATE 28P01, exactly as Postgres would.

TLS

Point BASIN_TLS_CERT_PATH and BASIN_TLS_KEY_PATH (or their _PEM variants) at a certificate and the listener negotiates TLS over the standard pgwire SSLRequest handshake — sslmode=require on the client side.

05 · hello, table

First queries

Nothing about the first five minutes is Basin-specific. Create a table, insert rows, query them. The interesting part is what is happening underneath: the insert lands in the WAL and the in-memory hot tier, and a compactor later folds it into a columnar file under the project's bucket prefix.

CREATE TABLE events (
  id         BIGSERIAL PRIMARY KEY,
  user_id    UUID        NOT NULL,
  kind       TEXT        NOT NULL,
  payload    JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO events (user_id, kind, payload) VALUES
  (gen_random_uuid(), 'signup', '{"plan":"free"}'),
  (gen_random_uuid(), 'login',  '{"ip":"10.0.0.4"}');

SELECT kind, count(*) AS n
FROM   events
WHERE  created_at > now() - INTERVAL '7 days'
GROUP  BY kind
ORDER  BY n DESC;

Prepared statements, the extended query protocol, cursors, COPY and binary parameter binding all work — including native binary binding for JSONB, UUID, BYTEA and array types. NUMERIC binds over the text wire format; binary varlena for numerics is deferred.

Look at what landed on disk. The extension tells you which format the table chose:

find /tmp/basin/projects -name '*.vortex'
# /tmp/basin/projects/01HABCD…/tables/events/data/2026/05/01/01HEFG….vortex
06 · the dialect

SQL surface

The support matrix is generated by the test suite, never hand-edited. Every fragment is run end-to-end against a fresh server in three parser configurations, and the result is written back into docs/sql-support.md.

975
SQL fragments in the corpus
872
Run end-to-end per configuration · 89.4%
27
Explicitly out of scope, not gaps

Excluding the 27 deliberate exclusions, 872 of 948 fragments (92.0%) execute end-to-end, and the count is identical across all three parser configurations — 2,616 green cells over three columns. Regenerate it yourself:

cargo test -p basin-integration-tests --test sql_support_matrix -- --nocapture

What works

AreaDetail
Wire protocolpgwire v3 — simple and extended query, TLS, COPY, prepared statements, cursors, binary parameters
DDLCREATE TABLE (incl. CREATE TABLE AS … WITH NO DATA), ALTER TABLE, CREATE INDEX, views, materialized views
DMLMulti-row INSERT, UPDATE, DELETE, ON CONFLICT DO NOTHING / DO UPDATE, MERGE INTO
QueriesJoins, CTEs, WITH RECURSIVE, window functions, GROUPING SETS, subqueries, DISTINCT ON, UNION/INTERSECT/EXCEPT
TypesJSONB (binary-encoded), UUID, BYTEA, arrays, TIMESTAMPTZ, NUMERIC, TSVECTOR, citext, vector(N)
SessionsPREPARE/EXECUTE, DECLARE/FETCH/MOVE, LISTEN/NOTIFY/UNLISTEN, EXPLAIN and EXPLAIN ANALYZE
SecurityENABLE ROW LEVEL SECURITY + CREATE POLICY, enforced at the logical-plan layer
FunctionsCREATE FUNCTION … LANGUAGE sql and … LANGUAGE wasm, CALL

The parser

Basin's shipping front end is sqlparser-rs. libpg_query — the real PostgreSQL grammar, vendored — is the canonical parser per ADR 0014 and is available behind BASIN_PG_QUERY=1, with a further BASIN_PG_QUERY_PLAN=1 stage that translates the Postgres AST straight to a DataFusion logical plan for single-table SELECT. The migration is in progress; CAPABILITIES.md marks it as such rather than as done.

07 · the gaps

Compatibility & gaps

Basin is Postgres-compatible, not Postgres. Two categories of divergence exist and they should be read differently: deliberate exclusions, which have an ADR and a stated trigger that would reopen them, and real gaps, which are bugs with a version attached.

Deliberate exclusions

Not supportedWhy, and what replaces it
CREATE EXTENSIONLoadable .so extensions run outside the project-scoped I/O wrappers and break the bucket-prefix IAM boundary. The common ones ship natively instead — see extension equivalents.
PL/pgSQL, triggersReplaced by SQL-bodied and Wasm functions plus the change-event primitive. Cursor loops and EXCEPTION handling inside a trigger body are an explicit non-goal.
Streaming replicationWrong shape for object-store storage — the bucket is already the replicated artefact. Change data is delivered by the realtime layer and the CDC ring.
postgres_fdw, dblinkNo federation. basin-net covers HTTP-shaped outbound reads from SQL.
PostGIS beyond POINTLINESTRING, POLYGON and GiST geo-indexes are not in the box; ST_DWithin and ST_Distance on POINT are.
pgvector IVF-flatHNSW only, and the text wire format only — the pgvector binary wire format is a non-goal.

Real gaps

These parse and plan but fail, or are rejected before execution. They are v0.2 targets and each is listed per-fragment in the generated matrix.

  • LATERAL joins — a correlated lateral subquery in FROM fails at execution.
  • WITH RECURSIVE combined with DML, e.g. WITH RECURSIVE … DELETE.
  • Advanced window frames: RANGE INTERVAL mode, GROUPS mode, and the EXCLUDE clause.
  • JSON_AGG(t) over a whole-row table reference.
  • EXCLUDE USING gist exclusion constraints on CREATE TABLE.
  • INSERT … SELECT as a single statement is rejected with an honest SQLSTATE; materialize the SELECT and insert the rows.
  • UPDATE … SET col = <expression> is partial — the right-hand side must currently be a literal or a single bind parameter — and UPDATE … WHERE col IN (SELECT …) is rejected.
ORM reality check

tests/integration/tests/orm_smoke.rs drives seven query patterns that real ORMs emit through the extended-query protocol — multi-row parameterised inserts, a cached prepared statement over 100 bind/execute cycles, mixed-type predicates, NULL round-trips, parameterised LIMIT, quote-bearing text, and BYTEA round-trips. All seven pass.

08 · shapes on disk

Types & indexes

Indexes in Basin are catalog metadata over columnar files rather than heap-pointing btrees, so the vocabulary is familiar but the mechanics differ. Point-query pruning leans on per-file catalog statistics and bloom filters as much as on any secondary structure.

-- Plain, partial and expression indexes
CREATE INDEX idx ON t (id);
CREATE INDEX idx_active ON t (id) WHERE id > 0;
CREATE INDEX idx_lower ON t (LOWER(name));

-- GIN, for JSONB containment and full-text
CREATE INDEX idx_gin ON t USING gin (name);

-- HNSW, for vector similarity
CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops);

Table-level storage hints

Basin exposes its physical layout through WITH options and ALTER TABLE rather than through knobs in a config file, so the choice travels with the schema:

basin.file_format
'vortex' (default) or 'parquet', fixed at CREATE TABLE time and persisted in the catalog.
CLUSTER BY (…)
Physical clustering key, so range scans over that column touch fewer files.
SET BLOOM FILTERS ON
Per-file bloom filters, which is what turns a point lookup from a scan into a prune.
SET row_group_rows
Row-group size — the granularity at which the reader can skip.
SET cold_after
How long before a partition is treated as cold and evicted from the hot tier.

Full-text search

to_tsvector, to_tsquery, the @@ match operator, ts_rank and ts_rank_cd are native, including a generated TSVECTOR column indexed with GIN:

CREATE TABLE doc (
  body TEXT,
  ts   TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', body)) STORED
);
CREATE INDEX ON doc USING gin (ts);

SELECT body, ts_rank(ts, to_tsquery('quick & fox')) AS rank
FROM   doc
WHERE  ts @@ to_tsquery('quick & fox')
ORDER  BY rank DESC;
09 · row visibility

Row-Level Security

ALTER TABLE … ENABLE ROW LEVEL SECURITY and CREATE POLICY behave as they do in Postgres, with the predicate injected at the logical-plan layer rather than filtered after the fact. auth.uid(), auth.role() and auth.jwt() are SQL session functions populated from the verified JWT at connection open, so Supabase-shaped policies transfer unchanged.

CREATE TABLE notes (
  id        BIGSERIAL PRIMARY KEY,
  owner_id  UUID NOT NULL DEFAULT auth.uid(),
  body      TEXT NOT NULL
);
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "own rows" ON notes FOR ALL USING (owner_id = auth.uid());

Both the schema-qualified auth.uid() and the underscore auth_uid() spellings resolve. An anonymous session returns NULL and 'anon', matching Supabase behaviour.

How it is tested

tests/integration/tests/security.rs runs 1,000-iteration cross-project fuzzes plus four explicit bypass shapes including UNION and CTE constructions. A P0 RLS bypass via UNION + CTE was found by that suite during development and fixed before the release tag — which is the argument for the suite existing, not an argument that the surface is finished.

10 · isolation

Projects & isolation

One project is one bucket prefix. A project's data lives under projects/<project_id>/… in your object store, and that prefix is the IAM boundary. Cross-project access is not a software check that could be wrong in some edge case; it is a bucket-policy denial. The engine resolves the project once, at connection accept, and refuses to read across the prefix afterwards.

  • Idle projects cost only their bytes. No backend process, no warm pool, no provisioned compute — around 2 KiB of resident RAM per idle project.
  • Active projects share a compute pool. The shard owner holds in-memory state for many projects per process and evicts on idle, with a five-minute default.
  • Per-project fairness is enforced. A semaphore caps each project at 16 concurrent storage operations, and an earliest-deadline-first scheduler prioritises latency-sensitive operations (HEAD, list, small range reads) over bulk PUTs, so one bursting customer cannot starve the rest.
  • Auth is per project. Identity tables live in the project's own auth schema. Cross-project user federation is an explicit non-goal for v0.1 — the project is the isolation primitive.
The honest counter-example

Deleting a project at small scale is slower than Postgres. DROP SCHEMA CASCADE is a few catalog rows and an unlink; Basin's deletion is O(file count). The prefix-delete advantage shows up on multi-gigabyte projects on real object storage, not on a 100k-row table on tmpfs.

11 · bytes

Storage format

Vortex is the default on-disk format, and has been since ADR 0015 was updated. Parquet remains first-class and selectable per table, which is the right choice when something outside Basin — Athena, Spark, DuckDB, an Iceberg reader — has to read the files directly.

-- Vortex is the default; state it explicitly if you like.
CREATE TABLE events (…) WITH (basin.file_format = 'vortex');

-- Parquet, for read-compat with external engines.
CREATE TABLE exports (…) WITH (basin.file_format = 'parquet');

Vortex measures 1.95× smaller on disk than ZSTD Parquet on Basin's own cards. It is not uniformly better: it currently trails on point-lookup latency (about 0.65× after catalog-stats file pruning) and on ORDER BY … LIMIT (about 0.38×), because native vortex-datafusion execution is still maturing.

Layout

projects/{project_id}/
  tables/{table}/
    data/{yyyy}/{mm}/{dd}/{ulid}.vortex      # or .parquet
    metadata/                                # Iceberg-style snapshots + manifests
_catalog/                                    # when BASIN_CATALOG=object_store

The catalog is Iceberg-style: tables, snapshots and manifests. It can live in memory (volatile, the default), in the object store itself under BASIN_CATALOG_PREFIX, or in an external Postgres. The object_store backend is the one worth knowing about — it gives you a durable, shared catalog with leases and no external database to provision.

12 · what survives

WAL & durability

Writes land in a file-backed write-ahead log before they are visible, and a compactor folds WAL segments into columnar files asynchronously. The WAL directory is BASIN_WAL_DIR and it wants a durable volume — ideally NVMe.

The default acknowledges an INSERT before fsync, with a bounded loss window of at most 200 ms. This is the single most important thing to understand before comparing Basin's write numbers to anyone else's, because most published Postgres numbers are fsync-durable per commit. If you want that guarantee, ask for it:

SET basin.synchronous_commit = on;

That gives group-committed fsync durability per statement. Measured cost in Basin's probe harness: about 2% on the 10k-row bulk INSERT, because group commit amortises one fsync across a statement group.

BASIN_WAL_MODE
local (default) is a single-node, file-backed WAL. raft enables the openraft path — today a single-process simulation; cross-process distributed WAL is v0.2.
BASIN_WAL_BACKEND
Where WAL segments go. Unset mirrors BASIN_STORAGE_BACKEND; set it to local to keep the WAL on disk while table data lives in a bucket.
BASIN_SYNCHRONOUS_COMMIT
Process-wide default for the session setting above.
BASIN_DATA_FSYNC
Whether data-file writes are fsynced.
13 · recent writes

HTAP hot tier

A columnar file on object storage is a wonderful thing to scan and a miserable thing to update one row of. The hot tier is the answer: a row-format, LSM-style memtable in front of the columnar files, holding recent writes and tombstones, merged into reads.

The UPDATE and DELETE fast paths are on by default as of the Phase 5.14 closure. If they misbehave in your workload you can roll them back without a redeploy by setting BASIN_HOTTIER_FASTPATH_DISABLE=1 — the kill switch is documented in ADR 0016.

The shape this buys, from the 2026-06-11 integrity cards: point-query p50 is sub-millisecond at every scale (0.06 ms at 10k rows, 0.12 ms at 100k, 0.50 ms at 1M) and single-row UPDATE p50 lands at 0.33 / 0.64 / 1.24 ms across the same scales. Postgres, with a primary-key btree, is still microsecond-class on those shapes. Read Benchmarks before drawing a conclusion.

14 · history

Snapshots & forks

Because the catalog is Iceberg-style, table history is a first-class object rather than a backup product. Two catalog operations matter:

rollback_to_snapshot
Catalog::rollback_to_snapshot(project, table, snapshot_id) rewinds a table to a previous snapshot.
fork_table
Catalog::fork_table(project, src, dst) clones a table's metadata and snapshot history into a sibling that diverges on the next commit. Zero data copied until divergence.
Not yet SQL

These are catalog APIs. AS OF SNAPSHOT and AS OF TIMESTAMP are not accepted by the parser today — the SQL surface for time travel is planned, not shipped. If you read "time travel" elsewhere and assumed a query clause, this is the correction.

15 · identity

Auth

basin-auth ships signup, signin, magic links, JWT issuance with refresh-token rotation, OAuth providers, MFA factors and per-project API keys. Identity tables — users, sessions, refresh_tokens, oauth_identities — live in the project's own auth schema, so an RLS policy can reference them without a cross-project hop.

POST /auth/v1/signup           { email, password }
POST /auth/v1/token?grant_type=password
POST /auth/v1/token?grant_type=refresh_token
POST /auth/v1/magiclink        { email }
POST /auth/v1/logout
GET  /auth/v1/user

Turning it on takes BASIN_AUTH_ENABLED=1, a JWT secret, and SMTP settings if you want email verification, magic links or password reset to actually deliver.

Off by default

Auth and REST are disabled in the default build and in the Docker image. Some pages in the repository imply the quickstart container serves /auth/v1/… on the pgwire port; it does not. REST listens on its own bind (BASIN_REST_BIND, default 127.0.0.1:5434) and only starts when auth is on.

16 · http

REST API

Every table is reachable as a PostgREST-compatible surface. No codegen step, no schema mirror file — create a table and the endpoints exist.

GET    /rest/v1/<table>?select=*&owner_id=eq.<uuid>
POST   /rest/v1/<table>          (body: row or [row, …])
PATCH  /rest/v1/<table>?id=eq.42
DELETE /rest/v1/<table>?id=eq.42
POST   /rest/v1/rpc/<fn>         (body: JSON named arguments)
GET    /rest/v1/_openapi.json    (OpenAPI 3.0, auto-generated)

Filters, ordering, keyset cursor pagination, NDJSON streaming responses, Prefer: return=representation, batch inserts and on_conflict=… upsert all follow the PostgREST shape. JWTs from basin-auth flow into SQL session state, so RLS gates an HTTP request exactly as it gates a pgwire query. The RPC mount dispatches to functions created with LANGUAGE sql or LANGUAGE wasm.

Fail-closed by construction

Setting BASIN_REST_ENABLED=1 without BASIN_AUTH_ENABLED=1 makes the server refuse to start, per ADR 0006. A REST stack without auth is the largest data-leak class the project knows how to ship, so it is a startup error rather than a warning in a log nobody reads.

17 · push

Realtime

Row-change events stream over SSE for browsers or WebSocket for richer clients. Presence channels — track, untrack, presence_state, presence_diff — ride the same WebSocket multiplex.

// Server-sent events: one table, no handshake to write.
const es = new EventSource("/realtime/v1/sse/proj_abc/notes");
es.addEventListener("message", (e) => applyChange(JSON.parse(e.data)));

// WebSocket multiplex: many tables plus presence on one socket.
const ws = new WebSocket("wss://example.com/realtime/v1/ws/proj_abc");
ws.onopen = () => {
  ws.send(JSON.stringify({ type: "subscribe", table: "notes" }));
  ws.send(JSON.stringify({ type: "subscribe", table: "audit_log" }));
  ws.send(JSON.stringify({ type: "presence.track", room: "doc:42",
                           payload: { user: "alice" } }));
};
ws.onmessage = (m) => console.log(JSON.parse(m.data));

Subscriber-side filter pushdown evaluates the predicate at the sink before bytes reach the wire, so a fanout that only cares about WHERE owner_id = $1 does not pay the serialize-then-drop cost. A per-project realtime memory budget keeps one runaway subscriber from starving its siblings. Default binds are 127.0.0.1:5435 for SSE and 127.0.0.1:5436 for WebSocket.

Separately, LISTEN/NOTIFY works at the SQL level with Postgres-accurate transaction buffering: a NOTIFY inside a transaction is queued and fanned out on COMMIT, discarded on ROLLBACK, channel names are case-insensitive, and pg_listening_channels() reflects session state. Use LISTEN/NOTIFY for in-database pub/sub and the realtime layer for push to web clients.

Harness-gated

The implementation is complete and single-client smoke tests run on every commit, but a few cross-client soak slices are still #[ignore]-gated. That is a coverage gap, not a known correctness gap — worth knowing which one you are relying on.

18 · files

Blob storage

basin-blob is a catalog-backed object store with a public /storage/v1/ REST surface, wired end to end.

  • Buckets — create, get, delete; deleting a bucket purges orphaned objects.
  • Objects — upload with server-side MIME sniffing, download, prefix list with paging, single delete and bulk delete by prefix.
  • Public fast pathGET /storage/v1/object/public/:project/:bucket/*path serves objects in public buckets with no JWT.
  • Signed URLs — HMAC-SHA256 over (project, bucket, path, expiry), constant-time verification, and independent signing-key rotation that invalidates outstanding tokens.
  • Per-object RLS — owner / role / true / false predicates with Postgres permissive OR-merge semantics, enforced on both download and list.
  • Quota accounting — a per-project bytes_written_total counter, incremented on upload and decremented on delete.

Deferred to a later version: HEAD and COPY object operations, resumable multipart/TUS uploads, image transforms, object versioning and cross-project object sharing. Quota enforcement — rejecting writes past a limit — is deliberately above this crate rather than inside it.

19 · similarity

Vector search

vector(N) is a native column type, not an extension and not a sidecar. The HNSW index lives per file alongside the columnar data. The planner auto-routes ORDER BY x <-> $1 LIMIT k to the HNSW probe, so there is no separate "use the index" query to write.

CREATE TABLE docs (
  id        BIGSERIAL PRIMARY KEY,
  text      TEXT NOT NULL,
  embedding vector(1536) NOT NULL
);
CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops);

SELECT id, text FROM docs
ORDER BY embedding <-> $1::vector
LIMIT 10;

Operators match pgvector: <-> for L2, <#> for negative inner product, <=> for cosine distance. Existing pgvector client code works, but CREATE EXTENSION pgvector is rejected — the type is already there. IVF-flat and the pgvector binary wire format are explicit non-goals.

20 · the usual suspects

Extension equivalents

The handful of extensions that real applications actually reach for are compiled into the binary as native crates. No CREATE EXTENSION step, no per-project .so install, no shared-library version skew across a fleet.

Postgres extensionBasin equivalentCrate
pg_croncron.schedule(…) plus the schedulerbasin-cron
pg_net, httpnet.http_get / net.http_post UDFsbasin-net
pg_trgm% similarity operator and trigram indexbasin-trgm
PostGIS (POINT subset)ST_DWithin, ST_Distance on POINTbasin-geo
TimescaleDB continuous aggregatesContinuous materialized viewsbasin-cv
pgcrypto, uuid-osspgen_random_uuid, crypt, digest, …core engine
SELECT cron.schedule(
  'gc-old-notes',
  '0 3 * * *',
  'DELETE FROM notes WHERE created_at < now() - INTERVAL ''90 days'''
);

CREATE MATERIALIZED VIEW notes_per_day
WITH (basin.cv = 'continuous', basin.refresh = '1 day')
AS
SELECT owner_id, date_trunc('day', created_at) AS day, count(*) AS n
FROM   notes
GROUP  BY 1, 2;
21 · compute

Wasm functions

In-engine compute runs as WebAssembly, sandboxed by wasmtime. The function body is a bare Wasm module — anything targeting wasm32-unknown-unknown that respects the host ABI, so Rust, Zig, Go or AssemblyScript all work.

CREATE FUNCTION square(n INT) RETURNS INT
LANGUAGE wasm AS '<base64-encoded-wasm-module>';

SELECT square(5);  -- → 25

v0.1 covers scalar arguments and returns of i32, i64, f64, text, bytea and timestamptz. Resource caps land per call — a linear-memory ceiling, an instruction budget via Wasmtime epoch interruption, and a fuel limit — so a runaway function is interrupted rather than pinning a thread.

Variable-length values cross the host/guest boundary over a (ptr, len) linear-memory ABI: the module exports memory, basin_alloc(i32) -> i32 and basin_dealloc(i32, i32); the host writes bytes into guest memory and the guest packs its return as a (ptr << 32) | len i64, with len = -1 signalling SQL NULL.

  • No dedicated JSONB argument type. Declare the argument text and parse the canonical JSON bytes inside the module.
  • Execution is per row. Vectorized whole-array invocation is deferred. For bulk string or JSONB transforms prefer LANGUAGE sql — the SQL surface already covers regexp_match, jsonb_path_query, jsonb_set, format, encode/decode and the rest of that family.
22 · the noes

Deliberate omissions

Every "no" in Basin has a one-page rationale that includes the trigger which would change the answer. Three are worth stating on this page because they are the ones people assume are coming.

Edge functions on a V8 isolate pool

Not shipped, not on the roadmap. The compute-close-to-data case is covered by two primitives that do not require an isolate pool: in-engine Wasm UDFs for per-row compute in the same process as the data, and declarative inbound webhooks plus the RPC mount, which cover most of the BaaS edge-function taxonomy as SQL. A geographically distributed V8 pool is a different product with a different operational shape.

Triggers and PL/pgSQL

Not shipped. Declarative lifecycle columns, SQL-bodied reactors and LANGUAGE sql/LANGUAGE wasm functions cover audit-log-on-INSERT, computed-column refresh and fanout-on-UPDATE. Cursor-driven loops and EXCEPTION handling inside a trigger body are the explicit remainder, and they are a non-goal.

Loadable extensions

Not shipped. An .so extension can read any project's pages out of shared memory, runs outside Basin's project-scoped I/O wrappers, and turns "one binary, one bill" into a dependency tree to patch. That is three broken invariants for one feature.

23 · clients

Client SDKs

Ten first-party SDKs, all MIT-licensed, all talking to a Basin engine directly over REST and pgwire. They live in the monorepo under sdks/ and are versioned alongside the engine they target: Dart, .NET, Go, Java, JavaScript/TypeScript, PHP, Python, Ruby, Rust and Swift. Each carries its own tests, README and roadmap.

import { createClient } from "@bas-in/basin-js";

const basin = createClient(process.env.BASIN_URL!, process.env.BASIN_ANON_KEY!);

await basin.auth.signInWithPassword({ email: "you@example.com", password: "…" });

const { data, error } = await basin
  .from<{ id: number; name: string; price: number }>("products")
  .select("id, name, price")
  .eq("active", true)
  .order("price", { ascending: true })
  .limit(10);

basin
  .channel("orders-feed")
  .on("postgres_changes", { event: "INSERT", table: "orders" }, (payload) => {
    console.log("new order:", payload.new);
  })
  .subscribe();
from basin import create_client

client = create_client(BASIN_URL, BASIN_ANON_KEY)

res = await client.from_("users").select("*").eq("active", True).limit(50)

# Cursor pagination with NDJSON streaming — a basin-specific affordance.
async for row in client.from_("events").select().paginate():
    ...

await client.auth.sign_in_with_password(email=..., password=...)
await client.functions.invoke("monthly_rollup", {"month": "2026-05"})

Parity, honestly

docs/sdk-parity.md is a capability-by-language grid, and the point of it is the holes. Filters, ordering, keyset pagination, CRUD, RPC, the full auth surface including MFA and OAuth, API keys, realtime change events with reconnect and replay, storage and a typed error model are uniform across all ten. These are not:

CapabilityWhere it exists
Arrow IPC transport5 of 10 — .NET, Go, JS, Python, Rust (feature-gated)
NDJSON streaming5 of 10 — Dart, JS, PHP, Python, Ruby
SQLSTATE on errors4 of 10 — .NET, Go, Java, Rust
Presence send (track/untrack)9 of 10 — Rust exposes a read-only event stream only
Realtime transportPHP needs an optional WebSocket package installed separately
Richer PostgREST filters (or, not, like, embeds)0 of 10
Transactions / BEGINCOMMIT0 of 10 — use pgwire directly
COPY / bulk-insert path0 of 10
Vector / similarity search helpers0 of 10 — use SQL via RPC

Package names are @bas-in/basin-js (npm and JSR) and basin-sdk on PyPI, importable as basin. Both are v0.x; the Python distribution is not published yet, so build it from sdks/py in the meantime.

24 · existing code

ORMs & migrations

Because the extended query protocol is real, ORMs that funnel through tokio-postgres, asyncpg or pgjdbc mostly work without knowing what they are talking to. Diesel, SeaORM, Prisma, SQLAlchemy and ActiveRecord are the shapes the smoke test models.

25 · terminal

Command line

basinctl is the administrative CLI for a running engine. It takes a connection string from --url or BASIN_URL and does the things you reach for at 2am.

basinctl ping postgres://alice@127.0.0.1:5433/alice   # SELECT 1, prints "OK in "
basinctl tables                                       # SHOW TABLES
basinctl query "SELECT count(*) FROM events"
basinctl version                                      # version + git short SHA

# Migrate schema and data from a live PostgreSQL.
basinctl import-from-postgres --source postgres://…

# Scaffold, build and deploy a Wasm function.
basinctl fn new hello && basinctl fn build && basinctl fn deploy

import-from-postgres enumerates schemas and tables through information_schema, translates DDL into Basin's dialect (serial becomes a BIGINT identity; uuid, jsonb, citext and vector pass through), skips what it cannot represent with a loud per-object report, then streams rows with binary COPY — falling back to CSV for types binary COPY rejects — and verifies row counts at the end.

basinctl reset-auth --yes drops the auth namespace and nothing else, for test environments and broken-state recovery; the next server restart rebootstraps an empty schema.

A second binary exists

The repository also carries cli/, a separate Rust binary named basin aimed at a hosted control-plane API rather than at an engine. Self-hosting needs only basinctl. Note that two docs pages describe this CLI as a Go binary — it is Rust; cli/Cargo.toml is the ground truth.

26 · knobs

Configuration

All server configuration is environment variables. There is no server config file — the .basin-test*.toml files at the repository root are integration test and benchmark fixtures read via BASIN_TEST_CONFIG, not something basin-server ever loads.

Core

BASIN_BIND
pgwire listen address. 127.0.0.1:5433 from source, 0.0.0.0:5432 in the image.
BASIN_DATA_DIR
Data root. Mount a volume here for persistence; /var/basin in the image.
BASIN_PROJECTS
Comma-separated user=project_id pairs; * allocates a ULID.
BASIN_CATALOG
memory (default, volatile), object_store (durable and shared, no external database), or a postgres://… DSN.
BASIN_CATALOG_PREFIX
Bucket prefix for the object-store catalog. Default _catalog.
BASIN_CATALOG_SCHEMA
Schema name for the Postgres catalog. Default basin_catalog.
BASIN_WAL_DIR
WAL directory. Wants a durable volume. In raft mode, log/vote/snapshot persist under ${BASIN_WAL_DIR}/raft.
BASIN_SHARD_ENABLED
1 routes writes through the WAL and compactor. Off by default.
BASIN_POOL_ENABLED
1 enables the native session pool, which caches warm sessions for short-lived clients. Off by default.
BASIN_MAX_CONNECTIONS
Connection ceiling.
BASIN_TLS_CERT_PATH / _KEY_PATH
TLS material; _PEM variants take the contents inline.

Subsystems

BASIN_AUTH_ENABLED
Turns on basin-auth. Pair with BASIN_AUTH_JWT_SECRET and the BASIN_AUTH_SMTP_* family.
BASIN_REST_ENABLED / BASIN_REST_BIND
REST layer and its own listen address, default 127.0.0.1:5434. Requires auth to be on or the process refuses to start.
BASIN_REALTIME_BIND / _WS_BIND
SSE and WebSocket binds, defaults 127.0.0.1:5435 and 127.0.0.1:5436.
BASIN_HOTTIER_FASTPATH_DISABLE
1 rolls back the UPDATE/DELETE fast paths without a redeploy.
BASIN_PG_QUERY / _PLAN
Opt into the libpg_query parser and the Postgres-AST-to-DataFusion planner.
BASIN_QUERY_COST_LIMIT_ROWS
Per-query row budget.
BASIN_CDC_RETENTION_HOURS
How long the change-data ring holds events.
BASIN_DISK_CACHE_* / BASIN_PAGE_CACHE_MAX_BYTES
The NVMe disk cache and in-memory page cache that keep hot files off the object store.

That is the load-bearing subset. The full surface is roughly 110 BASIN_* variables; services/basin-server/src/main.rs documents them at the top of the file, which is the only place guaranteed to be current.

27 · the bucket

Object storage

The same binary runs against the local filesystem, S3, or any S3-compatible store. Only the environment changes.

Basin keyAWS fallbackRequiredNotes
BASIN_STORAGE_BACKENDinferredlocal, s3 or tigris
BASIN_STORAGE_BUCKETBUCKET_NAMEyes
BASIN_STORAGE_ENDPOINTAWS_ENDPOINT_URL_S3noTigris defaults to https://fly.storage.tigris.dev
BASIN_STORAGE_REGIONAWS_REGIONnoDefaults: auto for Tigris, us-east-1 for S3
BASIN_STORAGE_ACCESS_KEY_IDAWS_ACCESS_KEY_IDyes
BASIN_STORAGE_SECRET_ACCESS_KEYAWS_SECRET_ACCESS_KEYyes
BASIN_STORAGE_SESSION_TOKENnoSTS deployments only
BASIN_STORAGE_ROOT_PREFIXnoSub-prefix inside the bucket
BASIN_STORAGE_ALLOW_HTTPnoRequired for a plaintext endpoint — otherwise a non-HTTPS endpoint is a hard error

If BASIN_STORAGE_BACKEND is unset but AWS_ENDPOINT_URL_S3 is present, the provider is inferred from the endpoint host — a tigris.dev host means Tigris, anything else means S3. Where both a canonical key and an AWS fallback are set, the BASIN_STORAGE_* key wins. Both behaviours are covered by unit tests rather than by hope.

Client tuning lives alongside: BASIN_S3_POOL_MAX_IDLE (64), BASIN_S3_POOL_IDLE_TIMEOUT_SECS (20), BASIN_S3_MAX_RETRIES (3), BASIN_S3_RETRY_TIMEOUT_SECS (30) and BASIN_S3_REQUEST_TIMEOUT_SECS (30).

28 · production

Deployment

The recommended shape is one Basin cluster per region, not per customer, with the object store in the same metro as the compute — single-digit-to-low-tens-of-milliseconds RTT, and ideally zero egress fees. Multi-region is a deployment decision, not a code path.

BASIN_BIND=127.0.0.1:5433 \
BASIN_CATALOG=postgres://postgres@127.0.0.1:5432/postgres \
BASIN_DATA_DIR=/tmp/basin \
BASIN_WAL_DIR=/tmp/basin/wal \
BASIN_PROJECTS='alice=*,bob=*' \
BASIN_SHARD_ENABLED=1 \
BASIN_POOL_ENABLED=1 \
BASIN_AUTH_ENABLED=1 \
  BASIN_AUTH_JWT_SECRET=$(openssl rand -hex 32) \
  BASIN_AUTH_SMTP_HOST=smtp.example.com BASIN_AUTH_SMTP_PORT=587 \
  BASIN_AUTH_SMTP_USERNAME=u BASIN_AUTH_SMTP_PASSWORD=p \
  BASIN_AUTH_SMTP_FROM=noreply@example.com BASIN_AUTH_SMTP_TLS=starttls \
BASIN_REST_ENABLED=1 BASIN_REST_BIND=127.0.0.1:5434 \
cargo run -p basin-server

The required set for production-shaped durability is BASIN_BIND, a durable BASIN_CATALOG, BASIN_DATA_DIR or BASIN_STORAGE_BACKEND, BASIN_WAL_DIR, BASIN_PROJECTS, and BASIN_AUTH_ENABLED if you want auth. Everything else is optional.

Multi-node

Raft and lease knobs turn a single process into a cluster. Treat this as the v0.2 edge of the project rather than as a settled surface — the shipped default is a single-node file-backed WAL.

BASIN_WAL_MODE
local (default) or raft.
BASIN_NODE_ID / BASIN_RAFT_BIND
Node identity and the raft listen address.
BASIN_RAFT_PEERS
Peer list, e.g. 1@10.0.0.1:6010,2@10.0.0.2:6010,3@10.0.0.3:6010.
BASIN_RAFT_TLS_*
_CERT, _KEY, _CA, _DOMAIN — default domain basin-raft.
BASIN_LEASE_MODE
off or required, with BASIN_LEASE_TTL_SECS (15) and BASIN_LEASE_RENEW_SECS (5).

Verifying a release

curl -fsSLO https://raw.githubusercontent.com/vul-os/basin/v0.1.9/scripts/verify.sh
bash verify.sh --tag v0.1.9 --attest basin-0.1.9-x86_64-unknown-linux-gnu.tar.gz
29 · day two

Observability

Be precise about what exists here, because the repository's own operator pages over-promise. Tracing is OpenTelemetry — tracing-opentelemetry with an OTLP exporter. On the REST bind there are two routes:

GET /health
Liveness.
GET /metrics/inflight
A process-global in-flight and latency snapshot, designed to be polled every ~15 s by a scaler that scales on p99 or in-flight count.
GET /admin/v1/usage
Per-project usage counters; /admin/v1/projects/:project_id/usage for one project.
There is no Prometheus /metrics endpoint

Several pages under docs/operators/ and one design document describe a Prometheus scrape endpoint and name metrics such as basin_memtable_bytes or basin_page_cache_hits_total. No /metrics route is registered anywhere in this repository, and no Prometheus exporter crate is a dependency. Those documents describe an external exporter or unbuilt work. Plan your monitoring against OTLP and /metrics/inflight.

Operator guides

30 · measurement

Benchmarks

Basin publishes every head-to-head number, wins and losses, regenerated from integration tests. The picture is workload-dependent and the losses are the interesting part, so here is the shape rather than a highlight reel.

Shape (1M rows, LocalFS, no index either side)BasinPostgres 18
LATERAL JOIN (correlated derived table)6.7 ms3,080 ms
Star join (events ⋈ users ⋈ categories)11.6 ms3,040 ms
Range scan p50 (~1k rows)0.40 ms32 ms
Bulk INSERT 1,000,000 rows2,080 ms8,100 ms
Point query p50 (unindexed PK)0.50 ms0.002 ms
Single-row UPDATE p501.24 ms0.012 ms
Deep top-K sort (ORDER BY … LIMIT 1000)161 ms53 ms
Bulk UPDATE (~1/3 of rows)9.4 s3.4 s
On-disk bytes (users + events)321 MB306 MB

Read those numbers with three caveats attached, all of which the project states itself. First, they come from a single idle-box local session on 2026-06-11, not a controlled lab. Second, Basin's default acknowledges writes before fsync while the Postgres numbers are fsync-durable per commit — see durability. Third, the on-disk row is an honest flip: Basin is 5% larger on this card, 1.9× smaller at 100k rows, and 102× smaller on the real-S3 card. A single number from any of these tables, quoted without its scale and its card, is misinformation.

The method behind every one of these numbers — the workload, the storage configs, how a latency figure is estimated, and why the losses are on the page at all — is the next section.

31 · how the numbers are made

Benchmark methodology

Everything on the Benchmarks section and in the headline block on the landing page comes out of the harness in this repository. This section is the method itself, not a pointer to it. The long-form document (benchmark/BENCHMARKS.md) carries the full result tables; the numbers there are regenerated from test reports and the file says so in its own header — do not edit by hand.

Workload shape

One table, one row shape, across every config — 1,000,000 synthetic audit-log rows, with 10 M-row variants where a card says so:

id          UUID
project_id  UUID
action      TEXT
created_at  TIMESTAMPTZ
payload     TEXT
Data
Rows come from a fixed-seed PRNG, so a run is repeatable — and so real-world data distributions will produce different numbers.
Columnar layout
Default row-group size 65,536 rows, ZSTD-1 compression.
Index policy
Neither Basin nor Postgres has a B-tree index on id. Basin's point-query speed comes from predicate pushdown plus bloom filters, not a B-tree. The results are substrate comparisons, not a claim that Basin replaces Postgres for every workload.
Baseline
Postgres 18, local, default config — no tuning of shared_buffers, work_mem or anything else. A tuned Postgres would close some gaps.

Three storage configurations

Every claim is scoped to one of three configs. A number quoted without its config is not a number.

ConfigSlugWhat it measures
LocalFSlocalfsPure architectural numbers — no network, no storage concurrency limits. Fastest and most stable.
Real S3-compatiblerealTigris / AWS S3 / Cloudflare R2 / Backblaze B2. Numbers customers will actually experience.
SeaweedFSseaweedfsSelf-hosted S3 gateway; a high-concurrent-read proxy — see the regime warning below.

How a latency number is estimated

There is one shared benchmark core, the basin-bench-harness crate. It owns engine and storage startup, project and table seeding, latency sampling, threshold checking and JSON sidecar emission. A profile is a BenchConfig builder plus a list of shapes, so swapping Parquet for Vortex is a config edit rather than a new test file.

Percentiles, never means
Samples land in an HDR histogram (hdrhistogram, bounds 1 µs to 60 s, three significant figures) and the reported figures are read off as quantiles — p50, p95, p99, p99.9. A mean would let one stall or one cold object fetch move a headline; the median will not. Sorting the full sample vector per shape would also be O(N log N), which is why the histogram is the storage rather than an array.
Cold is the claimed number
Every latency bar is measured cold — caches empty at the start of the run. Warm p50/p99 are reported alongside as corroboration but are never the primary claimed number.
Warm-up is discarded where it exists
The standalone speed suite under benchmark/speed/ runs SPEED_WARMUP iterations (default 100) and throws them away before timing SPEED_ITERATIONS (default 1,000).
Nothing is typed in by hand
Each dashboard reads <data_dir>/results.js, regenerated from the per-test JSON reports after a run, with a plain-text companion at RESULTS_<slug>.md. A test that has not been run renders as "not yet run" rather than as a blank or a stale value.
# LocalFS — fast, no cloud credentials needed
cargo test -p basin-integration-tests --tests -- --nocapture
python3 benchmark/bundle.py
open benchmark/index_localfs.html

# Real S3-compatible backend: fill in credentials first
cp .basin-test.toml.example .basin-test.toml
cargo test -p basin-integration-tests --tests -- --nocapture
python3 benchmark/bundle.py --dir data_real

# One harness profile, Markdown summary on stdout
scripts/run-bench.sh vortex-vs-parquet

Why the losses are published

A benchmark page that only contains wins is an advertisement with a table in it. The shape of where Basin loses is the part that tells you whether it fits your workload, so the losses are published on the same cards as the wins and the caveats are stated by the project itself:

  • Basin is Postgres-compatible, not Postgres. The pgwire protocol, SQL surface and driver compatibility are genuine, but the execution engine is DataFusion / DuckDB over columnar files, not the Postgres planner over a heap. Row-level UPDATE/DELETE hotspots, heavy JOIN fan-out and B-tree range scans on non-clustered data will be faster on Postgres.
  • An indexed Postgres beats the baseline shown. The point-query results use predicate pushdown and bloom filters against an unindexed Postgres. Add the index and Postgres wins that shape.
  • Project deletion crosses over. At 100 files per project Basin is faster (4.64 ms vs 5.91 ms); at 1,000 files it is slower (34.09 ms vs 6.15 ms) and at 5,000 files much slower (170.31 ms vs 2.26 ms), because every file has to be individually deleted from the object store while Postgres does one DROP SCHEMA CASCADE.
  • The analytical engine is LocalFS-only in v0.1. The DuckDB analytical routing path needs a local filesystem root, so those cards read 0× on the real-S3 dashboard. Real-S3 analytical benchmarks are v0.2 work.
  • On-disk size is not a one-way result. Basin is 12.5× smaller than Postgres on the 1 M-row LocalFS card and 5% larger on the users-plus-events card in the section above. Both are on the page.
The SeaweedFS cards are not a cloud claim

The data_seaweedfs dashboard runs Basin against a local SeaweedFS S3 gateway — roughly 1 ms/op round trip, no injected latency — versus an unindexed Postgres. It is a loopback structural-bug detector, not a cloud latency proxy. A headline like "point query 4× faster" on that card reflects that regime only: against an indexed Postgres the same point query is far slower, because a remote object GET loses to a buffer-cache B-tree probe. The cache-stack speedup bar is regime-aware for the same reason — on loopback the uncached baseline is already ~4 ms, so there is almost no latency left for the disk, page and bloom layers to compress.

The honest cloud framing is Basin on Tigris, at real object-store round-trip time, versus Postgres on EBS with its index. Reproduce that regime with the .basin-test.tigris-realistic.toml profile, which injects about 9 ms/op through a LatencyStore. Loopback SeaweedFS numbers should not be quoted as cloud performance.

32 · the map

Crate map

Twenty-seven crates under crates/ plus four services under services/. Descriptions below are each crate's own module documentation.

CrateWhat it is
basin-routerpgwire v3 front end
basin-engineSingle-process SQL execution engine — DataFusion planner, per-project sessions
basin-shardStateful shard owner, with the WAL-to-columnar compactor
basin-walWrite-ahead log for the shard owner
basin-storageProject-aware columnar-on-object-store substrate
basin-catalogTyped Iceberg-style catalog client used by the rest of the workspace
basin-hottierRow-format LSM-style hot-tier memtable for recent writes
basin-poolNative session pool, keyed on (ProjectId, ClientKey)
basin-placement(project_id, partition_key) → shard_owner_node map
basin-authAuth: signup, signin, JWT, OAuth, MFA, API keys
basin-restPostgREST-compatible HTTP layer
basin-realtimeWebSocket and SSE change-event fanout
basin-blobCatalog-backed object storage
basin-vectorNative vector search primitives — HNSW
basin-fnWasm component-model host ABI
basin-cronpg_cron-compatible scheduler
basin-netPostgres http / pg_net SQL surface
basin-trgmpg_trgm-compatible subset for fuzzy text matching
basin-geoPostGIS-compatible subset for the most common geo predicates
basin-cvTimescaleDB-style continuous aggregates
basin-cdcDurable change-data-capture ring plus SSE stream
basin-webhooksDisk-backed, idempotency-keyed webhook fanout
basin-sketchProbabilistic data-structure types shared across the workspace
basin-iceberg-restLakekeeper-compatible Iceberg REST catalog server
basin-autoscaleEngine shard autoscale controller library
basin-commonTypes, errors and telemetry shared by every crate
basin-bench-harnessUnified benchmark harness

Built on Apache Arrow, Apache Iceberg, Vortex, Apache Parquet, Apache DataFusion, Tokio, pgwire-rs and openraft.

33 · why

Design decisions

Twenty-nine numbered ADRs record the arguments, including the ones that closed doors. Each "no" carries the trigger that would reopen it, which is the part that makes them useful rather than decorative.

ADRDecision
0002No Postgres extension support
0003Native vector search — supersedes part of 0002
0005 · 0013 · 0020Auth system, per-project auth schema, OAuth + MFA
0006REST API layer, and its fail-closed dependency on auth
0008Noisy-neighbour fairness on bounded backends
0011Cross-shard 2PC — structurally rejected until there is demand
0012Change-event sink as the trigger / webhook primitive
0014libpg_query as the canonical parser
0015Vortex storage format — now the default
0016HTAP hot-tier architecture
0019Declarative BaaS surface: inbound webhooks and the RPC mount
0021Object storage — catalog-backed blobs
0023Lease-based ownership, partition routing, heartbeat budgets
0025 · 0026Postgres-compat surface: JSONB GIN, full-text, citext, session timeouts
0028CDC bridge architecture
Two numbering collisions

ADR numbers 0020 and 0021 are each used twice — 0020-auth-v2-oauth-mfa and 0020-wal-transaction-markers, 0021-docs-frontmatter-yaml and 0021-object-storage. Cite by filename rather than by number.

34 · joining in

Contributing

Open an issue before a pull request that adds new surface area. The ADRs exist so that scope arguments happen once, in writing, rather than in review — a patch that reopens a settled "no" is a conversation, not a diff.