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:
BASE=http://127.0.0.1:8787
TOKEN=your-token-hereAuth
Pass the serve token one of three ways (any match succeeds):
| Mechanism | Example |
|---|---|
| Query string | ?token=… |
| Header | Authorization: Bearer … (also accepts lowercase bearer ) |
| Header | X-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:
| Header | Value |
|---|---|
Access-Control-Allow-Origin | * |
Access-Control-Allow-Headers | Authorization, Content-Type, X-Eelden-Token, Eelden-Snapshot |
Access-Control-Allow-Methods | GET, POST, OPTIONS |
OPTIONS any path → 204 with those CORS headers (no auth check).
Snapshot pin
Optional pin for snapshot-scoped reads. Accepted from either:
| Source | Example |
|---|---|
| Header | Eelden-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 …/collectionsPOST …/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
{"ok":true,"tenant":"default"}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)
{
"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.
curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/health/detail"GET /api/v1/stats
Compact engine stats (Studio footer).
Response 200
{
"page_count": 1,
"wal_bytes": 0,
"checkpoint_lsn": 0,
"corruption_count": 0,
"db_dir": "./mydb"
}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
{
"tenants": [
{
"id": "default",
"numeric_id": 1,
"suspended": false,
"forked_from": null,
"schema_version": 1
}
]
}forked_from is a numeric tenant id or null.
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
{
"collections": [
{ "name": "users", "row_estimate": 3 }
]
}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)
{
"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.
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
{
"snapshots": [
{ "name": "before", "pages": 2, "created_at": 1710000000 }
]
}created_at is Unix seconds. pages is the number of collection heads in the snapshot.
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
| Param | Required | Default | Description |
|---|---|---|---|
from | yes | — | Snapshot name (before) |
to | yes | — | Snapshot name (after) |
collection | no | users | Collection to diff |
limit | no | 200 (clamped 1…2000) | Max ids per bucket |
Response 200
{
"collection": "users",
"from": "before",
"to": "after",
"added": ["…"],
"removed": ["…"],
"changed": ["…"]
}400 {"error":"…"} if from/to missing or pin/select fails.
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
| Param | Required | Default | Description |
|---|---|---|---|
after | no | 0 | Exclusive LSN cursor — events with lsn > after |
collection | no | all collections | Filter to one collection name |
Response 200
{
"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}.
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).
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
| Form | Notes |
|---|---|
{"query":"…"} | Preferred |
{"text":"…"} | Accepted |
{"weft":"…"} | Legacy alias (one-release compatibility) |
| Raw non-JSON body | Entire 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:
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:
{
"kind": "selected",
"affected": 1,
"columns": [{ "name": "name", "type": "Text" }],
"rows": [{ "name": "Ada" }],
"plan": { "access": "scan", "collection": "users" }
}| Field | Meaning |
|---|---|
kind | inserted | selected | updated | deleted | atomic |
affected | Row count (1 for insert) |
columns | {name, type} from typecheck shape / schema |
rows | Result objects (insert → [{"id":"…"}]) |
plan | {access:"scan"|"index", collection, field?} or null (atomics) |
statements | Present 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
{"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.
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
{
"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
op | name | target | Effect |
|---|---|---|---|
create | new tenant name (default: path) | — | create_tenant |
fork | source tenant (default: path) | required new name | fork_tenant |
snapshot | source tenant (default: path) | required snap name | snapshot_tenant |
suspend | tenant to suspend (default: path) | — | suspend_tenant |
delete | tenant to delete (default: path) | — | delete_tenant |
compact | — | — | bind path tenant if present, then compact |
Unknown op → 400. Missing target on fork/snapshot → 400 with a specific message.
Response 200
{"ok":true,"message":"created tenant 'acme' id=2"}# 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)
| Condition | HTTP |
|---|---|
| Bad/missing token | 401 {"error":"unauthorized"} |
| Unknown tenant | 404 {"error":"unknown tenant"} |
Missing Sec-WebSocket-Key | handshake 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:
{"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[]):
{"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.
# 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:
| Request | Behavior |
|---|---|
GET / or /index.html | Serve 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).