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 in
Phase 1b 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:
{ "error": { "code": "UNKNOWN_TARGET", "message": "unknown target 'foo'" } }
Stable codes: UNKNOWN_TARGET, UNKNOWN_MODEL, UNKNOWN_FIELD,
NOT_A_RELATION, NO_PRIMARY_KEY, INVALID_PRIMARY_KEY, UNSUPPORTED,
FORBIDDEN, VALIDATION_ERROR, DATABASE_ERROR, UPSTREAM_ERROR,
INTERNAL_ERROR. Writes use the Phase 3 codes (FORBIDDEN,
VALIDATION_ERROR); see the write API page for the
mutation surface.
Phase 1b adds SQLite drivers (via rusqlite), relation follow, and
API-backed list/get. Many-to-many through a junction table still
returns UNSUPPORTED.
Endpoints
GET /api/targets
{
"workspace": "platform",
"targets": [
{
"key": "catalog",
"display_name": "Catalog",
"mode": "rw",
"has_db": true,
"has_api": false
}
]
}
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.
{
"models": [
{
"name": "Post",
"primary_key": "id",
"fields": [
{ "name": "id", "type_name": "String", "arity": "required", "is_id": true, "is_relation": false },
{ "name": "title", "type_name": "String", "arity": "required", "is_id": false, "is_relation": false }
]
}
]
}
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:
| Param | Type | Default | Notes |
|---|
cursor | string | none | Pass back what next_cursor returned |
limit | integer | 50 | Hard cap of 500 |
{
"rows": [
{ "id": "abc", "title": "Hello", "body": null }
],
"next_cursor": "abc"
}
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. 404 with INVALID_PRIMARY_KEY if no
row matches.
{
"row": { "id": "abc", "title": "Hello", "body": null }
}
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.
{
"rows": [
{ "id": "p1", "authorId": 1, "title": "first" },
{ "id": "p2", "authorId": 1, "title": "second" }
],
"next_cursor": null
}
-
Required / Optional arity (
author User @relation(...)) —
returns a single optional row.
{ "row": { "id": 1, "email": "alice@example.com" } }
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:
{
"rust": "let row = cool.post()\n .find_unique(\"abc-123\".to_owned())\n .run(&ctx)\n .await?;\n"
}
Primary-key literals are typed:
String / Cuid / Uuid / Decimal → "value".to_owned(),
with quotes and backslashes escaped.
Int → 42_i64.
Other primary-key scalars (DateTime, Bytes, etc.) return 501 UNSUPPORTED in Phase 1a.
Errors
| HTTP | Code | When |
|---|
| 400 | INVALID_PRIMARY_KEY | The PK in the URL didn’t match a row, or wasn’t valid for the model’s @id type. |
| 400 | NO_PRIMARY_KEY | The model has no @id field. Studio v0 requires one. |
| 400 | NOT_A_RELATION | The :field segment on /rel/:field exists but isn’t typed as another model. |
| 404 | UNKNOWN_TARGET | The :key segment doesn’t match a [[target]] in studio.toml. |
| 404 | UNKNOWN_MODEL | The :model segment isn’t declared in the target’s .cstack. |
| 404 | UNKNOWN_FIELD | The :field segment on /rel/:field doesn’t exist on the model. |
| 501 | UNSUPPORTED | Unsupported PK scalar, relation follow against API targets, many-to-many junctions, or another operation Studio can’t service. |
| 500 | DATABASE_ERROR | sqlx or rusqlite error talking to the target’s database. Message is the underlying driver text. |
| 500 | INTERNAL_ERROR | SQLite blocking-task panic. Should never fire under normal operation; if it does, please report a bug. |
| 502 | UPSTREAM_ERROR | HTTP failure talking to a deployed cratestack service (network error, non-success status, malformed body). |
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.
# First page
curl 'http://127.0.0.1:7878/api/targets/catalog/models/Post/records?limit=100'
# -> { "rows": [...], "next_cursor": "post_99" }
# Next page
curl 'http://127.0.0.1:7878/api/targets/catalog/models/Post/records?limit=100&cursor=post_99'
When next_cursor is null, you’ve hit the end.
Secrets in studio.toml
Phase 1a resolves two reference forms on boot:
[target.db]
url = "env:CATALOG_DB_URL" # reads $CATALOG_DB_URL
driver = "postgres"
[target.api.auth]
kind = "bearer"
token = "file:/run/secrets/catalog" # reads & trims the file's contents
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.
Phase 2 widens relation resolution.
- 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 policy-validation
pass-through) land in Phase 3.
Driver coverage
| Driver | List | Get | Follow | Notes |
|---|
| Postgres | ✅ | ✅ | ✅ | Via sqlx with row_to_json projection. |
| SQLite | ✅ | ✅ | ✅ | Via rusqlite with json_object(...) projection. Sidesteps the libsqlite3-sys conflict. |
| Deployed API | ✅ | ✅ | ❌ | Talks to the service’s generated /api/<plural-snake> REST routes. Follow is UNSUPPORTED. |