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:
ParamTypeDefaultNotes
cursorstringnonePass back what next_cursor returned
limitinteger50Hard 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.
  • Int42_i64.
Other primary-key scalars (DateTime, Bytes, etc.) return 501 UNSUPPORTED in Phase 1a.

Errors

HTTPCodeWhen
400INVALID_PRIMARY_KEYThe PK in the URL didn’t match a row, or wasn’t valid for the model’s @id type.
400NO_PRIMARY_KEYThe model has no @id field. Studio v0 requires one.
400NOT_A_RELATIONThe :field segment on /rel/:field exists but isn’t typed as another model.
404UNKNOWN_TARGETThe :key segment doesn’t match a [[target]] in studio.toml.
404UNKNOWN_MODELThe :model segment isn’t declared in the target’s .cstack.
404UNKNOWN_FIELDThe :field segment on /rel/:field doesn’t exist on the model.
501UNSUPPORTEDUnsupported PK scalar, relation follow against API targets, many-to-many junctions, or another operation Studio can’t service.
500DATABASE_ERRORsqlx or rusqlite error talking to the target’s database. Message is the underlying driver text.
500INTERNAL_ERRORSQLite blocking-task panic. Should never fire under normal operation; if it does, please report a bug.
502UPSTREAM_ERRORHTTP failure talking to a deployed cratestack service (network error, non-success status, malformed body).

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.
# 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

DriverListGetFollowNotes
PostgresVia sqlx with row_to_json projection.
SQLiteVia rusqlite with json_object(...) projection. Sidesteps the libsqlite3-sys conflict.
Deployed APITalks to the service’s generated /api/<plural-snake> REST routes. Follow is UNSUPPORTED.