Studio Read API

Once cratestack studio run is up, Studio serves a stable JSON API over the bind address (default 127.0.0.1:7878). The Leptos UI is one consumer; you can hit the same endpoints from a script, a notebook, or your own tool. All responses are JSON. All error responses use the same envelope:
Stable codes: UNKNOWN_TARGET, UNKNOWN_MODEL, UNKNOWN_FIELD, NOT_A_RELATION, NO_PRIMARY_KEY, INVALID_PRIMARY_KEY, UNSUPPORTED, FORBIDDEN, VALIDATION_ERROR, UNSAFE_DB_WRITE, DATABASE_ERROR, UPSTREAM_ERROR, INTERNAL_ERROR. FORBIDDEN, VALIDATION_ERROR, and UNSAFE_DB_WRITE are only ever returned for writes; see the write API page for the mutation surface.
Studio supports Postgres and SQLite (via rusqlite) natively, plus API-backed targets that proxy through a deployed CrateStack service. Relation follow works against the two DB-backed drivers; API-backed targets return UNSUPPORTED for follow, and many-to-many through a junction table returns UNSUPPORTED on every driver — see What’s not here yet.

[target.db] is a direct database connection

Studio is an admin tool. A [target.db] target opens a direct SQL connection with database-level access by design — it is not a proxy in front of your deployed service’s generated API, and it does not replay that service’s request-time behavior. Two things follow from that, and both are intended, not bugs:
  • @@allow policies are never evaluated, on reads or writes, against a [target.db] target. A @sensitive or role-gated field is readable through Studio’s HTTP API by anyone who can reach it — unauthenticated, regardless of what the schema’s policies declare.
  • @@internal(...) route suppression is not enforced either. A model whose create is suppressed with @@internal("create") — or denied outright by an @@allow rule — is still creatable through POST /api/targets/{key}/models/{model}/records on an rw [target.db] target. Suppression is a generation-time routing decision about the generated API; a direct SQL connection sits beneath it, the way psql does.
  • Writes additionally bypass @version bumping and @@emit(...) event-outbox rows; see the unsafe-write guard on the write API page.
The only pre-flight checks on a [target.db] write are TargetMode::Rw, the @version/@@emit write-routability guard, and its allow_unsafe_writes opt-in. Making Studio enforce @@internal while @@allow stayed unenforced was considered and rejected as the arbitrary half of the change. An [target.api]-only target is the opposite and gets both for free: it issues ordinary HTTP requests against the deployed service’s generated routes — the same surface the TypeScript and Dart clients consume — so a suppressed verb has no route to call (405, or 404 when every verb on the path is suppressed) and policies are evaluated server-side against the identity in [target.api].auth.
[target.db] wins when a target declares both. The workspace loader takes [target.db] whenever the target declares one, so a target carrying both blocks is a [target.db] target for every read and write. Adding [target.api] alongside an existing [target.db] buys no enforcement — to get policy and route-suppression enforcement, the target must declare [target.api] and no [target.db].
Prefer an [target.api]-only target for a schema whose policies you need enforced, and scope which network can reach a [target.db]-backed Studio instance accordingly — Studio binds 127.0.0.1:7878 by default, which keeps it off the network unless you deliberately rebind it or put it behind a proxy.

Endpoints

GET /api/targets

GET /api/targets/:key/schema

Returns an OwnedSchemaSummary — parsed mixins, models, types, enums, and procedures by name.

GET /api/targets/:key/models

Same models, but with per-field detail and primary-key resolution.
is_relation is true when the field’s declared type names another model in the same schema (the relation-follow endpoint lands in Phase 1b).

GET /api/targets/:key/models/:model/records

Cursor-paginated list of rows. Query params:
next_cursor is null when the page didn’t fill — i.e. you’ve hit the end. Rows are ordered by primary key ascending; the cursor is the last seen @id value, serialized as a text-shaped reference that Studio binds and casts in SQL.

GET /api/targets/:key/models/:model/records/:pk

Single row by primary-key value. 400 with INVALID_PRIMARY_KEY if no row matches.

GET /api/targets/:key/models/:model/records/:pk/rel/:field

Follow a @relation field from a specific row. The response shape depends on the field’s arity:
  • List arity (posts Post[] @relation(...)) — returns a paginated page (same shape as /records), with cursor-based pagination on the target model’s primary key.
  • Required / Optional arity (author User @relation(...)) — returns a single optional row.
The resolver reads @relation(fields: [SRC], references: [TGT]) on the source field. CrateStack’s parser requires @relation on both sides of a relation, which lets Studio treat both directions uniformly: the target table is filtered on references[0], and the bound value comes from the source row’s fields[0]. Errors specific to this endpoint:
  • 404 UNKNOWN_FIELD — the :field segment isn’t a field on :model.
  • 400 NOT_A_RELATION — the field exists but isn’t typed as another model.
  • 501 UNSUPPORTED against an [target.api] target — the generated REST surface doesn’t expose arbitrary column filters, so relation traversal needs a [target.db] block.

GET /api/targets/:key/models/:model/snippet?pk=…

The “copy Rust query” generator. Returns a ready-to-paste find_unique call against the macro delegate:
Primary-key literals are typed:
  • String / Cuid / Uuid / Decimal"value".to_owned(), with quotes and backslashes escaped.
  • Int42_i64.
Other primary-key scalars (DateTime, Bytes, etc.) return 501 UNSUPPORTED.

Errors

Cursor pagination in practice

The cursor is opaque, but the model is straightforward: it’s the PK value of the last row in the page, encoded as a string.
When next_cursor is null, you’ve hit the end.

Secrets in studio.toml

Studio resolves two reference forms on boot:
Unset env vars and missing files raise a MissingEnv / SecretFile error at load time, and the error message names the bad config field so you don’t have to grep your studio.toml for which env: line broke.

What’s not here yet

  • Many-to-many through a junction table returns UNSUPPORTED on every driver. Not yet implemented.
  • Relation follow against API targets returns UNSUPPORTED — the generated REST surface doesn’t expose arbitrary column filters, so the target needs a [target.db] block for traversal.
Mutations (create / update / delete, with the same validator pass-through) are not part of the read API at all — see the write API.

Driver coverage