Studio

fenec studio is the server's own admin page: its collections, their rows and their schema in a browser, the edits you make there, a query editor, the rows as they change and the server's own numbers. Start the server with --studio and open /_studio/, or try it on the Playground, where it runs over a database in your own tab. On a Mac there is an app too (Desktop app).

Turn it on

Add --studio to fenec-server, open /_studio/ on the same address, and paste a token.

fenec-server --file data.fenec --http 127.0.0.1:8080 \
         --http-token "$TOKEN" --studio

open http://127.0.0.1:8080/_studio/
fenec studio: the collections on the left with their row counts and a collection's fields and indexes, 100 000 orders in a grid with counts by status and owner above it, and one row unfolded on the right, its json field as a tree and its vector summarised
100 000 orders, counted by their @hash fields, one row unfolded.
  • Off by default. Without --studio, /_studio/ answers 404, as an unknown path does.
  • No new authority. The page reads and writes with the token you paste, over the same HTTP surface every client uses.
  • A tool for operators: keep it on a private network or behind your own sign-in (Security).
How the studio is served, and its size

The studio is plain ES modules and CSS from the repository's studio/ folder, with the HTTP client and query builder of @fenecdb/web/client beside them — no framework and no build step. The server embeds the files, so the binary is all you deploy. Its first load is 67 KB of JavaScript and CSS, gzipped, its modules preloaded together from the page; the query editor, the schema, the live rows and the admin view are fetched the first time each is opened, 7 to 19 KB more. The server sends the files as they are, uncompressed; a proxy in front may compress them.

A router serves it too: fenec-shard --studio puts the same pages on the router's address, where each tenant is reached through the router (Tenants).

Connect

The server is the page's own address unless you name another, and the token is the one the server takes: its --http-token, or a JSON Web Token its policy holds to its rows. The top bar says who the server takes it for.

  • Your token stays in the tab. It is kept in session storage and forgotten when the tab closes: never in local storage, a cookie or a URL.
  • Who you are: the token's subject, role and tenant, the rules that apply to it, and how long until its exp. The line under the top bar shortens as the token's life runs out, and the page warns you five minutes before it does.
What /_whoami tells the page

The identity comes from GET /_whoami, which any client may ask: open on a server that asks for no token, full for the server's token, and scoped for a JSON Web Token, with each rule of the policy that applies to it — the collection, the grants, the fields an update may change, the fields a read shows (read_fields, null for every one) and the rows, as the policy writes the filter. A token is shown its own rules and no other's: a rule for a role it does not hold is not among them. It grants nothing; a token the server refuses gets the same 401 here as anywhere.

curl -H "Authorization: Bearer $ALICE" http://127.0.0.1:8080/_whoami
# {"node":"single","tenant":null,"read_only":false,"kind":"scoped","sub":"alice",
#  "tenants":null,"unbound":false,"append_only":[],
#  "rules":[{"collection":"orders","grants":["read","insert","update","delete"],
#            "fields":null,"read_fields":null,"rows":"owner = $jwt.sub"}]}

The token's claims — its subject, role and exp — are read in the page to show them, and decide nothing: the server checks the token on every request.

Collections and rows

The sidebar lists the collections the token can read, each with its rows and its fields' types and indexes. The grid shows a collection's rows, as many as it holds, read a page at a time as you scroll.

  • Sort by id, or by a field an index orders: @sorted or @ttl.
  • Filter with a where clause in FenecQL and its parameters, or a box under each column: abc, =v, >=v, a..b, null.
  • Counts by value for each @hash field, over what the filter selects; pick one to filter by it.
  • A row unfolded (Space): a json field as a tree, a vector as its dimension, its length and its first components.
  • File size and what a compact would give back, where the token may read /_metrics.
How rows are paged and drawn

A page is 100 rows. In id order the page after one already read is asked for by its last id — get orders where id > $1 limit 100 — which walks the id index from there; a page reached by a jump, or under an order, by its offset. Only the rows in view are drawn, so 100 000 rows cost what 40 do: a scroll down the whole collection and back in Chrome took no task over 50 ms (make studio-test). Past 8 million pixels of rows the scroll bar stands for the rows in proportion, since browsers cap an element's height.

The count and the counts by value are one /batch of reads, under one read lock, so they are of one moment. A vector is never written out whole: 768 numbers in a cell are noise.

Edit

Enter writes a cell, N opens a form for a new row, and Delete removes one after you confirm the statement it will send.

set orders {note: $1} where id = $2 require 1
insert orders {customer: $1, total: $2}
del orders where id = $1 require 1
  • Every value is a parameter. A text shaped as FenecQL is stored as it reads.
  • Every write is a /batch with an Idempotency-Key, so a retry cannot write it twice.
  • A refusal says why: 403 names the field the policy refused, and 412 says the row changed — deleted, or no longer the token's — since it was read.
How an edit is checked and written

require 1 makes a write that found no row a refusal (412) rather than a quiet affected 0, so an edit of a row someone deleted meanwhile is told so. The form is built from the schema: a box a field, typed as the field is, the statement written out below it as you fill it. A name FenecQL cannot write is refused rather than spliced into the text, and the where clause you type is held inside parentheses it cannot close, so it stays one condition among the page's.

Query

The Query tab is an editor for FenecQL. It runs exactly what you type, with your token: it can do what the token may, and nothing more.

fenec studio's query editor, dark: a get over orders with two parameters, coloured as the docs colour FenecQL, its 200 rows in the grid under it with counts by owner, the status, the time, the rows and the request id beside the tabs, and the saved and past queries on the right
A query with its parameters, its rows, and the request id to find it in the logs.
  • Run with Ctrl+Enter or ⌘Enter; a selection runs alone.
  • Parameters are a JSON list, bound to $1, $2…
  • Several statements, separated by ;, run as one /batch: they land together or not at all. Give their parameters as a list each, [[1], ["a"]].
  • The answer as rows or as JSON, with its status, its time, its Fenec-Seq for a write and its X-Request-Id.
  • The plan of a get: the Plan tab asks the server for explain of the same text with the same parameters.
  • A refusal shows the server's message and status, and for a batch which statement stopped it, which the editor selects.
  • History and saved queries stay in this browser. The token is never kept there.
How the editor highlights and splits statements

The colours are the docs' own rules: site/build.py writes the same patterns and keywords into the editor's highlighter, and site/test_highlight.py holds the two to the same output. The text is drawn over the textarea as text nodes, so the caret, selection, input methods and undo are the browser's. Nothing you type is rewritten: the editor only cuts the text at each ; outside strings and comments, and a statement goes to POST /query, several to POST /batch, as any client sends them.

Schema

The Schema tab shows a collection as the statements that make it, and its fields. With the server's token you can add an index or a field, rename or drop a field, and drop the collection.

fenec studio's schema view, light: adding a sorted index on the total field of orders, the dialog showing the create index statement it will run and the same statement from the server's declared-schema plan
An index on its way: the statement, and what the server's plan says of it.
  • Every change is shown before it runs: the exact statement, and what POST /_schema/plan says of the schema as it would be after it.
  • A drop waits for its name. Dropping a field or a collection runs only once you type its name.
  • A scoped token reads the schema of the collections it reads, and changes nothing.
create index on orders (total) @sorted
alter collection orders add field due timestamp @ttl(30d)
alter collection orders rename field note to memo
alter collection orders drop field memo
drop collection scratch
How a schema change is planned

The statements come from the server: GET /_schema?as=fenecql answers the description with its collections as FenecQL, written by the engine. The plan is asked of the collection as it would be after the change, and of no other, so it speaks of this change alone. It writes the statements an index or a field added would need, and refuses to guess a field that is gone: a drop or a rename? The statement says which. A change runs as a /batch under an Idempotency-Key, as the studio's other writes do. A name FenecQL cannot write is refused, and an index's options and a ttl's duration are checked before they reach the text.

Live

The Live tab follows a collection's rows as they change: new and rewritten rows are marked as they arrive, deleted ones struck through before they go.

fenec studio's live view, light: the orders of one customer, a new row and a rewritten one marked at the top, and the changes listed on the right with their change numbers
One customer's orders as they are written, each change listed.
  • A where clause picks the rows to follow.
  • Pause holds the changes back and counts them; resume applies them in order.
  • A scoped token sees its own rows: the server holds the stream to them, deletions included.
How the live view is bounded

The view is a subscription, GET /<name>/changes with your filter as its shape: first every row it selects, then each write. So a shape of more than 10 000 rows is refused before it opens: narrow it with the where clause. Under a scoped token the server ANDs the token's filter into the shape and keeps the ids it sent, so a row the token may not read never reaches the page, nor does the deletion of one.

Admin

The Admin tab is shown to the server's own token. It reads the statements by shape, the server's counts and, on a router, its tenants.

fenec studio's admin view, dark: statement shapes ordered by their mean time with their calls, maximum, rows and errors, and the server's reads, writes, latency, file size and reclaimable bytes beside them
The slowest shapes, and the server's numbers.
  • Statements: the slowest and the most run shapes, from /_stats/statements. Pick one to open it in the editor.
  • Server: reads, writes, errors and their latency, the file and what a compact would give back, memory and compactions, from /_metrics, read again every 5 seconds.
  • On a router: each tenant with its node, state and size on disk, from GET /_shard/tenants?bytes.
When the Admin tab appears

The tab appears when /_whoami says the token is the server's, or the server asks for none; the server decides again on every request. Through a router the tenants' list takes the router's --token: start the router with the nodes' data token and one token reads both.

In your browser

The site's Playground is this studio — the same files the server embeds — over a database in your browser tab rather than a server's: fenecdb compiled to WebAssembly, on a worker of the tab, seeded with products, orders and a week of events. Nothing is sent anywhere. Rows, the query editor, the schema and the live rows work there as they do here; the query editor lists examples to run.

On a serverIn your browser
A token pasted in, its subject and expiry in the top barRunning in your browser. in its place, and nothing to sign in to
The tenant picker on a node or a routernone: one database
The admin view, the file's sizes in the sidebarnone: those are a server's numbers
Writes through /batch with an Idempotency-Keythe same statements, one block each batch
Live rows through a subscriptiona live query over the module's change ring
Kept in the server's filekept nowhere unless asked: Keep it in this browser stores it in IndexedDB, and Reset data puts every collection back
How a page in the browser answers like a server

Every view reads through one surface: run, batch and subscribe as FenecHttp has them, and request for an answer shown whole — the editor's status and change, the schema and its plan. Over a server that surface is HTTP (studio/connect.js); in the page it is the browser module in a dedicated worker (studio/local.js, worker.js), which answers each request as a server would: the same rows, the status a server refuses a statement with, the change a write leaves the database at, the schema as the engine describes it (Fenec.describe) and a batch as one block (Fenec.batch). studio/test/transport.test.mjs sends the same statements to a real fenec-server and to the module and holds every answer equal. A server's first load holds none of it: the page's own modules load only where a page asks for them.

Keys

/the where clause
Alt+1, Alt+2the collections, the view
arrows, Page Up, Page Downmove between cells
Enter, F2edit the cell
Spacethe whole row
Ccopy the row as JSON
Ssort by the column, where its index orders it
N, Deletea new row; delete the row, once confirmed
R, ?read the rows again; every key
1 to 5rows, query, schema, live, admin (1 to 4 in your browser)
Ctrl+Enter, ⌘Enterrun the query, in the editor

Tenants

On a tenant node or a router, the top bar picks the tenant: those the token's tenant claim names, or the node's or router's list where the token may read it. Otherwise type the tenant when you connect.

fenec-server --dir tenants --http 127.0.0.1:8081 --admin-token "$ADMIN" --studio
fenec-shard --listen 127.0.0.1:8090 --directory shard.fenec --studio
Tenants, tokens and origins

A token is bound to the tenants it names on a node, so the picker offers nothing it would be refused. Through a router the studio is on the router's address and every tenant is that same origin; a page served by a node would be another origin to the router. --studio-connect <origin> lets the pages a server serves send requests to one more origin, which its content security policy names.

fenec studio on a phone, light: the orders grid with its where clause above fenec studio on a phone, dark: the same grid
On a phone, light and dark: the collections slide in from the side.

In an app

An app that shows the studio in a window of its own — a desktop app over the server it started for a file — hands the page its session rather than having a token pasted. Before the page's modules run, from a preload script, it defines window.fenecHost — on the server's origin alone:

// The origin of the server the app started, or the one it connected to.
const ORIGIN = 'http://127.0.0.1:54321';

// A preload runs in every page the window loads: a redirect's, a sign-in
// page's. Any other origin gets nothing.
if (location.origin === ORIGIN) {
  Object.defineProperty(window, 'fenecHost', {
    value: Object.freeze({
      session: Object.freeze({ server: ORIGIN, token, tenant: null }),
    }),
  });
}

Keeping the token from other origins is the app's job. A page of another origin that finds window.fenecHost reads the token; the studio cannot hide it from one. Compare the origin as above, and keep the window on the server's origin.

The page's own world. A preload that runs in an isolated world defines fenecHost where the page never sees it. Under Electron's default context isolation, expose it through the bridge, and check in the page that what arrives passes Object.isFrozen, which the studio asks of the session:

const { contextBridge } = require('electron');

if (location.origin === ORIGIN) {
  contextBridge.exposeInMainWorld('fenecHost', {
    session: { server: ORIGIN, token, tenant: null },
  });
}
  • The page's own server. The studio takes the session only when it is frozen and names the origin that served the page; otherwise it says the app handed it none, and does not send the token.
  • Never stored. The token is not written to session storage: a reload takes the session from the app again.
  • The app holds the connection. There is no sign-in form and no Sign out. A token the server refuses is said as the app's connection refused, to be made again from the app.

The token is sent as a pasted one is, so the page has no more authority than the token. Start the server with --exit-on-stdin-close and a pipe on its stdin, and it stops cleanly however the app ends.

Desktop app (beta)

On a Mac with Apple silicon the studio is also an app: open a database file, make a new one, or connect to a server, with no server of your own to start. It is not published for download yet; build it from source.

make studio-desktop

open "integrations/studio-desktop/build/stable-macos-arm64/fenec studio.app"
  • A file. File, Open… (or New Database…, or a double-click in Finder). The app starts a fenec-server it carries, on the file, and opens a window on that server's own /_studio/, so the window does what the studio does in a browser. Closing the window stops the server once it has written its checkpoint.
  • A saved server. A name, an address, a token and, for a tenant node, a tenant. The token is kept in the Keychain, never in the app's own files, and is sent only to the address it was saved for. The window is on that server's studio, so there is no CORS to set up. The server must run with --studio.
  • A message, not a blank window. A file another program has open, a server that stopped, one that cannot be reached, one with no studio and a token it refuses each say so in a window of the app's own, with the way forward.
  • macOS only for now, Apple silicon, and not yet signed. An app you build opens as it is; an unsigned one you downloaded needs xattr -cr "fenec studio.app" first.
How the token is kept, and what the build needs

A token is never in a URL or on a command line: a file's server is given a random one in its environment, and the window hands it to the studio as window.fenecHost, frozen and never stored, only on that server's origin, before any of the page's code runs. A server the app started does not outlive it: its stdin is a pipe the app holds, and the server stops when it ends, kill -9 of the app included. A studio window cannot call into the app (it has an event bridge that carries a message one way, and nothing else) and cannot leave its server's origin: a link to another site opens in your browser. The Keychain keeps the token from other users and from the pages, not from another program of yours: that runs as you, as the app does.

The build needs Node, npm and Rust, and fetches its build tool into the project's own directory: nothing is installed globally. The directory's README has the build, the checks, the failures and what each says, the secrets that sign it and what a first signed build still has to be checked for.

Security

The studio is a new surface, not a new authority. Treat it as you would the server's own port: keep it on a private network, or behind your own sign-in and TLS.

authoritynone of its own: every statement carries the pasted token, so a scoped token sees its rows alone — in the grid, the counts and the facets
the tokensession storage of one tab, or in an app the session it hands over, stored nowhere; sent only as Authorization: Bearer
headersContent-Security-Policy with default-src 'self', no inline script, connect-src 'self' and the --studio-connect origin; X-Frame-Options: DENY; nosniff; no referrer
valuesshown as text, never as markup; sent as parameters, never spliced
the editorruns what you type, as typed, with your token: no more authority than the token
schema changesshown as their statement and the server's plan before they run; a drop runs once its name is typed
this browserkeeps the editor's history and saved queries, and the theme; never the token
cachingthe page is never kept; its modules are asked about again each load (ETag, 304)
offwithout --studio, 404