Studio

npx turbine studio launches a local web UI for browsing your database and composing queries visually. It is read-only by default: no row editing, no DDL, no raw-SQL input surface, and every query executes inside a BEGIN READ ONLY transaction. --write opts a single launch in to primary-key-addressed edits through the same validated builder; without the flag, the write endpoints do not exist.

DATABASE_URL=postgres://user:pass@localhost:5432/mydb npx turbine studio

Studio introspects the schema, starts an HTTP server on 127.0.0.1:4983 (Node's built-in http module, no extra dependencies), and opens your browser with a per-process session token in the URL.

Try it without a database#

--demo boots Studio against a seeded, in-memory sample database. No connection string, no setup:

npx turbine-orm@latest studio --demo

The sample dataset (users, posts, comments, orgs, with relations and two PII-tagged columns) is served from Turbine's SQLite engine over node:sqlite's :memory: database, so it needs no DATABASE_URL and adds no dependency. Requires Node 22.5+.

A Read-only / Show PII / Write switcher in the demo banner toggles PII redaction and writes live, so you can try the redaction behavior and the write flow back to back. Every launch boots read-only with PII redacted. Writes apply to the in-memory store (edits stick, a refresh shows them), but nothing is ever saved anywhere: the store dies with the process and every launch starts pristine.

Launching#

npx turbine studio                          # default: 127.0.0.1:4983, opens browser, read-only
npx turbine studio --port 5173              # custom port
npx turbine studio --no-open                # don't auto-open the browser
npx turbine studio --schema inventory       # non-public Postgres schema
npx turbine studio --include users,posts    # only these tables
npx turbine studio --exclude _migrations    # hide tables
npx turbine studio --write                  # opt in to PK-addressed writes (loud warning)
npx turbine studio --show-pii               # reveal PII-tagged values (loud warning)
npx turbine studio --demo                    # seeded in-memory sample DB (no DATABASE_URL)
npx turbine studio --host 0.0.0.0 --allow-remote  # non-loopback requires explicit opt-in

The connection string resolves the same way as every other CLI command: --url flag, then DATABASE_URL, then turbine.config.ts. See CLI, config resolution.

FlagDescription
--port <n>HTTP port (default: 4983).
--host <addr>Bind address (default: 127.0.0.1). Non-loopback values are refused unless you also pass --allow-remote.
--allow-remoteOpt in to binding on a non-loopback --host. Emits a loud warning and proceeds. Without it, Studio exits with code 1.
--writeEnable insert/update/delete from the Data tab: single rows, multi-select delete, and paste-to-insert batches, every row addressed by its full primary key. Emits a startup warning; the UI shows a persistent WRITE MODE banner.
--show-piiShow PII-tagged column values instead of the redaction placeholder. Emits a startup warning.
--demoSeeded in-memory sample database; nothing is saved. Enables the in-UI Read-only / Show PII / Write switcher. Requires Node 22.5+.
--no-openDon't open the browser automatically.
--include <a,b> / --exclude <a,b>Limit which tables Studio sees.
--schema <name>Postgres schema (default: public). Pinned via set_config('search_path', ...) on every query.

The Query tab, a visual findMany builder#

The Query tab (the default) composes real Turbine queries, not SQL. You pick a table and build up findMany arguments:

  • Fields, select / omit column picks.
  • Filters, the full where operator set (equals, not, in, gt/gte/lt/lte, contains, startsWith, endsWith, IS NULL / IS NOT NULL, case-insensitive mode) with AND / OR / NOT grouping. Incomplete clauses explain themselves instead of silently disabling Run.
  • Order + limit, orderBy on any column, limit on the result.
  • Relations, drill into with recursively, to any depth, choosing fields, filters, ordering, and limits at every level.

A live preview shows the exact db.table.findMany({ ... }) call your builder state compiles to, and Copy TS puts it on your clipboard, so a query you prototype in Studio pastes straight into your codebase. After a run, View SQL shows the single statement the query compiled to, and Copy SQL copies it.

Because the builder speaks ORM args instead of SQL, the server can validate everything: POST /api/builder checks every identifier (table, relation, field, orderBy column) against the introspected schema and compiles the query with the same QueryInterface.buildFindMany the library uses, every value bound as a $N parameter. There is no string of SQL anywhere in the request.

Data and Schema tabs#

  • Data, browse table rows with column sorting, search across every text column, per-column filters (equals, not, contains, comparisons, null checks; stackable, all parameterized server-side), and a modal viewer for JSON/JSONB cells. Select rows with the checkbox column to copy or export them (JSON/CSV); double-click any cell to copy its value. Page size is adjustable up to 500 rows. In write mode, this tab also hosts the row editor and batch tools.
  • Schema, inspect tables, columns, types, indexes, and relations as introspected. Columns tagged pii: true carry a pii badge.

Both tabs go through the same parameterized query path as the builder; reads stay inside BEGIN READ ONLY even in write mode.

Zone-less timestamp and date cells render as UTC, using the same parsers the client registers (from one shared helper, so the two cannot drift). A cell therefore shows the same instant your application reads, and in --write mode an edit echoes the same value back. turbine mcp applies the same parsers to the rows it samples. See Zone-less columns.

Write mode (--write)#

By default there is nothing to opt out of: the write endpoints are not registered, requests to them 404, and every transaction is read-only at the database level. --write changes that for a single launch:

  • The Data tab gains Insert row, Edit, Delete, multi-select Delete selected, and Paste rows (bulk insert from pasted TSV/CSV with a header row, or a JSON array). The UI shows a persistent red WRITE MODE banner.
  • Every write is addressed by a full primary key: a single row, or a capped batch (max 500) of PK-addressed statements in one all-or-nothing transaction. No predicate-based mutation, no bulk update, no DDL, no SQL input surface: writes compile through the same validated buildUpdate / buildCreate / buildDelete builders as the library, identifiers checked against the introspected schema, values bound as $N parameters.
  • Each write request runs in its own transaction with the same parameterized statement_timeout and pinned search_path as reads. A batch where any row fails, or any PK matches nothing, rolls back entirely.
  • The row editor is typed: enum and boolean columns get dropdowns, JSON columns validate before submit, timestamps get ISO 8601 placeholders.
  • Write requests additionally require a matching Origin header; absent or mismatched origins are rejected.

Write mode is for fixing a few bad rows without opening psql, not for data entry. Tables without a primary key are not editable. Views are not shown at all: Studio introspects tables only, so a view never appears in the sidebar or the schema API (--include-views is a pull / generate flag, and studio has no equivalent).

PII redaction#

Columns tagged pii: true in defineSchema render as a redaction placeholder in every tab: table rows, builder results, nested relation rows, and the echoed row after a write. Redaction happens server-side, before the value is serialized into the API response, so the browser never receives the value. Redacted columns are also excluded from the Data tab's substring search, per-column filters (even IS NULL, since null-ness is information), and sorting, so a hidden value cannot be inferred those ways; their headers say so on hover. --show-pii turns redaction off for a launch, with a startup warning and a persistent in-page banner.

One nuance in write mode: the row echoed immediately after a write never contains PII values regardless of --show-pii, because the write statement excludes those columns at the SQL level; the Data grid shows them on its next refresh. See PII fields for how the same tags behave in application queries.

Saved queries and the command palette#

Queries you save persist to .turbine/studio-queries.json in your project (commit it to share a query library with your team). In --demo mode saved queries live in memory only. Saved queries appear in the sidebar and in the Cmd+K command palette, which also jumps to any table or tab in one keystroke.

Keyboard shortcuts: Cmd+Enter runs the current query, Cmd+S opens the Save dialog for it (the dialog's Save button confirms; the browser's own save dialog is suppressed), G then Q / D / S switches tabs, R refreshes the Data tab, Shift+R reloads the schema, / focuses the sidebar filter.

Saved queries are builder-state only. Entries saved as raw SQL by old Studio versions are ignored on load, with a console notice; Studio has no way to execute them.

Security model#

Each layer assumes the layer above it failed:

  1. Loopback binding. Binds 127.0.0.1 by default and refuses non-loopback --host values unless you pass --allow-remote. Studio is a single-user local tool, not a deployable service.
  2. Per-process token auth. A random 24-byte (192-bit) hex token is generated at startup and required on every /api/* request, compared in constant time. The token lives in the launch URL and a SameSite=Strict, HttpOnly cookie.
  3. No raw-SQL surface. No endpoint accepts SQL text. Builder and write requests are ORM args, validated identifier-by-identifier against the introspected schema; all values are $N parameters.
  4. BEGIN READ ONLY on every read. Even if every check above were bypassed, Postgres itself rejects writes on the read path. Without --write, this covers every request Studio can make.
  5. Write-mode gates. With --write: full-primary-key addressing on every mutation, per-request transactions with the same timeout and schema pinning, and Origin checks.
  6. Statement timeout. A 30-second transaction-local statement_timeout (parameterized set_config, never interpolated) bounds runaway queries; search_path is pinned to the configured schema the same way.
  7. Request hygiene. Per-session rate limiting (100 requests / 60 s), cross-origin requests refused, and security headers on every response: a nonce-based CSP (script-src 'self' 'nonce-...'), X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer.

Deliberately not implemented, in any mode: raw SQL, DDL, and predicate-based mutations (UPDATE ... WHERE over arbitrary conditions). Every mutation names its rows by primary key. Studio is for inspection and the occasional targeted fix; use the CLI, migrations, or your own code for everything else.

See also#

  • CLI, every command, flag, and the config resolution order.
  • API Reference, the findMany surface the builder compiles to, and the includePii read option (unlocked by the UNSAFE symbol, never by true).
  • Observability, npx turbine observe, the metrics dashboard with the same security model.