Skip to content

HTTP API

Local HTTP/1.1 API served by eelden serve / eelden studio. Base path: /api/v1. Loopback bind, stdlib-only server (single-threaded accept loop).

Auth is required on every /api/* route. Non-API paths serve the Studio static UI (or a placeholder). Capability honesty: Status. UI client: Studio. Ops context: Operations, CDC.

Examples below assume serve printed a URL like http://127.0.0.1:8787/?token=TOKEN. Set:

bash
BASE=http://127.0.0.1:8787
TOKEN=your-token-here

Auth

Pass the serve token one of three ways (any match succeeds):

MechanismExample
Query string?token=…
HeaderAuthorization: Bearer … (also accepts lowercase bearer )
HeaderX-Eelden-Token: …

Missing/wrong token on /api/*401 {"error":"unauthorized"}.

WebSocket tails should use the query-string form — browsers cannot set an Authorization header on the WebSocket constructor, and Studio puts the token in the URL.

CORS

Every JSON/UI response (and OPTIONS) includes:

HeaderValue
Access-Control-Allow-Origin*
Access-Control-Allow-HeadersAuthorization, Content-Type, X-Eelden-Token, Eelden-Snapshot
Access-Control-Allow-MethodsGET, POST, OPTIONS

OPTIONS any path → 204 with those CORS headers (no auth check).


Snapshot pin

Optional pin for snapshot-scoped reads. Accepted from either:

SourceExample
HeaderEelden-Snapshot: <snapshot-name>
Query?snapshot=<snapshot-name>

Header wins if both are present (Eelden-Snapshot, else snapshot query). Empty values are ignored.

Applied on:

  • GET …/collections
  • POST …/query

Pin is cleared after the handler returns. Unknown snapshot names surface as handler errors (typically 400 with {"error":"…"}).

Studio’s time-travel control sets this header.


Endpoints

GET /api/v1/health

Liveness probe.

Response 200

json
{"ok":true,"tenant":"default"}
bash
curl -sS "$BASE/api/v1/health?token=$TOKEN"

GET /api/v1/health/detail

Trust panel: corruption reports (identity + counts), WAL/checkpoint lag, recovery stats, fsync latency histogram, page count, db dir.

Response 200 (shape)

json
{
  "ok": true,
  "corruption": {
    "total": 0,
    "reports": [
      {
        "identity": "…",
        "count": 1,
        "last_seen_unix_secs": 0
      }
    ]
  },
  "wal": {
    "current_lsn": 0,
    "checkpoint_lsn": 0,
    "lag_bytes": 0,
    "bytes": 0
  },
  "recovery": {
    "replayed_frames": 0,
    "open_micros": 0
  },
  "fsync": {
    "count": 0,
    "max_micros": 0,
    "total_micros": 0,
    "buckets": [{ "le_micros": 100, "count": 0 }]
  },
  "page_count": 1,
  "db_dir": "./mydb"
}

Overflow fsync bucket uses "le_micros": null. See Health & compact.

bash
curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/health/detail"

GET /api/v1/stats

Compact engine stats (Studio footer).

Response 200

json
{
  "page_count": 1,
  "wal_bytes": 0,
  "checkpoint_lsn": 0,
  "corruption_count": 0,
  "db_dir": "./mydb"
}
bash
curl -sS -H "X-Eelden-Token: $TOKEN" "$BASE/api/v1/stats"

GET /api/v1/tenants

List live catalog tenants (/api/v1/tenants or trailing slash).

Response 200

json
{
  "tenants": [
    {
      "id": "default",
      "numeric_id": 1,
      "suspended": false,
      "forked_from": null,
      "schema_version": 1
    }
  ]
}

forked_from is a numeric tenant id or null.

bash
curl -sS "$BASE/api/v1/tenants?token=$TOKEN"

GET /api/v1/t/:tenant/collections

Collection names (DB + schema registry union) with row estimates. Binds the tenant, then applies pending lazy migration (data-touch path). Honors snapshot pin.

Response 200

json
{
  "collections": [
    { "name": "users", "row_estimate": 3 }
  ]
}
bash
curl -sS "$BASE/api/v1/t/default/collections?token=$TOKEN"
curl -sS -H "Eelden-Snapshot: before" \
  "$BASE/api/v1/t/default/collections?token=$TOKEN"
# or:
curl -sS "$BASE/api/v1/t/default/collections?token=$TOKEN&snapshot=before"

GET /api/v1/t/:tenant/schema

Schema IR for Studio: fields, indexes, source text, fingerprints, pending migration flag. Does not apply lazy migration (read-only status).

Response 200 (shape)

json
{
  "schemas": [
    {
      "name": "users",
      "version": 1,
      "fields": [
        { "name": "email", "type": "Text", "indexed": true }
      ]
    }
  ],
  "eel": "schema users { … }",
  "fingerprint": "<64 hex>",
  "tenant_fingerprint": "<64 hex>",
  "pending_migration": false
}

pending_migration is true when the tenant’s recorded fingerprint differs from the deployed schema fingerprint.

bash
curl -sS "$BASE/api/v1/t/default/schema?token=$TOKEN"

GET /api/v1/t/:tenant/snapshots

Named snapshots for the tenant, sorted by created_at.

Response 200

json
{
  "snapshots": [
    { "name": "before", "pages": 2, "created_at": 1710000000 }
  ]
}

created_at is Unix seconds. pages is the number of collection heads in the snapshot.

bash
curl -sS "$BASE/api/v1/t/default/snapshots?token=$TOKEN"

GET /api/v1/t/:tenant/diff

Compare two named snapshots for one collection (id sets: added / removed / changed).

Query parameters

ParamRequiredDefaultDescription
fromyesSnapshot name (before)
toyesSnapshot name (after)
collectionnousersCollection to diff
limitno200 (clamped 1…2000)Max ids per bucket

Response 200

json
{
  "collection": "users",
  "from": "before",
  "to": "after",
  "added": ["…"],
  "removed": ["…"],
  "changed": ["…"]
}

400 {"error":"…"} if from/to missing or pin/select fails.

bash
curl -sS "$BASE/api/v1/t/default/diff?token=$TOKEN&from=before&to=after&collection=users&limit=200"

GET /api/v1/t/:tenant/tail (HTTP poll)

CDC poll without a WebSocket upgrade. See also WebSocket.

Query parameters

ParamRequiredDefaultDescription
afterno0Exclusive LSN cursor — events with lsn > after
collectionnoall collectionsFilter to one collection name

Response 200

json
{
  "cursor": 42,
  "events": [
    {
      "lsn": 41,
      "op": "insert",
      "collection": "users",
      "id": "01H…"
    }
  ]
}

Resume by passing the returned cursor (or any event’s lsn) as the next after. Event shape is shared with WS text frames: {lsn, op, collection, id}.

bash
curl -sS "$BASE/api/v1/t/default/tail?token=$TOKEN&after=0"
curl -sS "$BASE/api/v1/t/default/tail?token=$TOKEN&after=40&collection=users"

GET /api/v1/schema.eel

Raw schema source loaded at serve start (text/plain; charset=utf-8).

bash
curl -sS "$BASE/api/v1/schema.eel?token=$TOKEN"

POST /api/v1/t/:tenant/query

Execute exactly one pipeline or one atomic { … } block. Binds tenant, applies pending lazy migration, honors snapshot pin.

Body — query text keys

FormNotes
{"query":"…"}Preferred
{"text":"…"}Accepted
{"weft":"…"}Legacy alias (one-release compatibility)
Raw non-JSON bodyEntire body treated as query text

Keys are matched as JSON object keys (not substrings inside values). Prefer query; error text still mentions deprecated weft if none match.

Rejected program shapes

/query rejects (error string):

  • schema decls / use schema
  • named-query decls
  • tenant ops

Message:

text
studio /query accepts a single pipeline or atomic block (no schema/tenant ops)

Also rejects zero or multiple queries/atomics (“expects exactly one…”). Load schemas at serve start; use /ops for catalog ops.

Success response 200

Single query:

json
{
  "kind": "selected",
  "affected": 1,
  "columns": [{ "name": "name", "type": "Text" }],
  "rows": [{ "name": "Ada" }],
  "plan": { "access": "scan", "collection": "users" }
}
FieldMeaning
kindinserted | selected | updated | deleted | atomic
affectedRow count (1 for insert)
columns{name, type} from typecheck shape / schema
rowsResult objects (insert → [{"id":"…"}])
plan{access:"scan"|"index", collection, field?} or null (atomics)
statementsPresent on atomic only — per-statement {kind, affected, columns, rows}

Atomic top-level also merges statement rows into top-level rows / columns (best-effort) with "kind":"atomic" and "plan":null.

Error response 400

json
{"error":"…","conflict":false}

conflict is true when the error string contains atomic conflict (first-committer-wins); otherwise false. Parse/typecheck/runtime failures use the same envelope.

bash
curl -sS -X POST "$BASE/api/v1/t/default/query?token=$TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"query":"users |> filter active == true |> select { name }"}'

# snapshot-pinned read
curl -sS -X POST "$BASE/api/v1/t/default/query?token=$TOKEN&snapshot=before" \
  -H 'Content-Type: application/json' \
  -d '{"query":"users |> take 10"}'

# raw body
curl -sS -X POST "$BASE/api/v1/t/default/query?token=$TOKEN" \
  -H 'Content-Type: text/plain' \
  --data-binary 'users |> take 1'

POST /api/v1/t/:tenant/ops

Tenant catalog / maintenance operations. If the path tenant exists it is bound first; create may invent a tenant that does not yet exist.

Body

json
{
  "op": "create" | "fork" | "snapshot" | "suspend" | "delete" | "compact",
  "name": "…",
  "target": "…"
}

op is required. name defaults to the path tenant when omitted. target defaults to empty string.

Field matrix

opnametargetEffect
createnew tenant name (default: path)create_tenant
forksource tenant (default: path)required new namefork_tenant
snapshotsource tenant (default: path)required snap namesnapshot_tenant
suspendtenant to suspend (default: path)suspend_tenant
deletetenant to delete (default: path)delete_tenant
compactbind path tenant if present, then compact

Unknown op400. Missing target on fork/snapshot → 400 with a specific message.

Response 200

json
{"ok":true,"message":"created tenant 'acme' id=2"}
bash
# create
curl -sS -X POST "$BASE/api/v1/t/default/ops?token=$TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"op":"create","name":"acme"}'

# fork
curl -sS -X POST "$BASE/api/v1/t/default/ops?token=$TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"op":"fork","name":"default","target":"sandbox"}'

# snapshot
curl -sS -X POST "$BASE/api/v1/t/default/ops?token=$TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"op":"snapshot","target":"before"}'

# compact path tenant
curl -sS -X POST "$BASE/api/v1/t/default/ops?token=$TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"op":"compact"}'

Language-level tenant ops also run under eelden run. See Operations.


WebSocket live tail

GET /api/v1/t/:tenant/tail with Upgrade: websocket completes an RFC 6455 handshake (101) and registers a live subscriber. Non-upgrade GETs use the HTTP poll response above.

Auth & errors (before 101)

ConditionHTTP
Bad/missing token401 {"error":"unauthorized"}
Unknown tenant404 {"error":"unknown tenant"}
Missing Sec-WebSocket-Keyhandshake error (logged)

Query parameters

Same as poll: token (auth), after (default 0), optional collection.

Hello frame

Immediately after upgrade, the server sends a text frame:

json
{"type":"hello","tenant":"default","cursor":0}

cursor echoes the negotiated after value. The server then backfills retained events with lsn > after, advancing the exclusive resume cursor.

Event frames

Each CDC event is one text frame (same JSON object as poll events[]):

json
{"lsn":41,"op":"insert","collection":"users","id":"01H…"}

Resume after disconnect by reconnecting with ?after=<lsn> (exclusive). The resume cursor rides the URL, not client messages. Server answers ping with pong and honors close.

bash
# Example with websocat (if installed):
websocat "ws://127.0.0.1:8787/api/v1/t/default/tail?token=$TOKEN&after=0"

More context: CDC.


Static UI routes

When --ui / default probe finds a Studio dist:

RequestBehavior
GET / or /index.htmlServe index.html
GET /<asset> (non-/api/)Serve file under UI dist; unknown paths fall back to index.html (SPA)
Path containing ..400 {"error":"bad path"}

With no UI dist: placeholder HTML explaining how to build clients/studio or pass --ui. API routes still work.

Unknown /api/… routes → 404 {"error":"not found"}. Panics during a request are caught and answered with 500 JSON (accept loop continues).

Pre-alpha. Local-first. Stdlib-only Rust engine. Tenant concerns shifted left into the database.