Browse docs All docs
Docs / Read your app's data — schema and SQL
Guidedeploymill://guides/database/access

Read your app's data: schema and SQL

Once an app has a managed database, you can inspect and read its data through the agent surface, without ever putting the connection string in front of the agent. This guide covers the two direct-data-access tools and how they fit together:

  • describe_database: the schema (shape only, never row values). Free, all plans.
  • query_database: a single read-only SQL query, capped and audited. Paid.

Both work on a prod app or a preview app (a branched preview carries its own managed-database record). Both are also available on the REST /api/v1 surface (paid plans — REST API access is a paid capability).

Heads-up on previews: a clone-mode preview's branched database contains real production rows copied at branch time. Reading it reads prod-shaped data, so treat it accordingly.

Inspect the shape: describe_database

Start here. describe_database returns the schema only: tables, columns (name, type, nullability), primary keys, indexes, and an approximate row count per table. It never returns a single row of data, so it's free on every plan (including the Explore free tier).

describe_database({ applicationId })
describe_database({ applicationId, table: "todos" })   // just this table
describe_database({ applicationId, limit: 10, offset: 10 })
{
  "ok": true,
  "applicationId": "app_123",
  "databaseManaged": true,
  "provider": "postgres",
  // Live size vs. the org's `maxDatabaseGb` ceiling. Present whenever the app HAS
  // a managed database (including the `describe_unsupported` case, where size is
  // still measurable). `measuredBytes`/`measuredGb` are null when size is unknown.
  "storage": {
    "measuredBytes": 8388608,
    "measuredGb": 0.01,
    "ceilingGb": 10,
    "overCeiling": false
  },
  "schema": {
    "tables": [
      {
        "name": "todos",
        "columns": [
          { "name": "id", "dataType": "uuid", "nullable": false },
          { "name": "title", "dataType": "text", "nullable": false }
        ],
        "primaryKey": ["id"],
        "indexes": [{ "name": "todos_pkey", "columns": ["id"], "unique": true }],
        "approxRowCount": 42
      }
    ],
    "totalTables": 1
  },
  "limit": 25,
  "offset": 0,
  "hasMore": false
}

The table list is a window. Every table comes back with all of its columns and indexes, so a wide schema described whole is a big result. table narrows to one table by exact name — the filter is pushed into the catalog queries, so the rest of the schema is never read, and an unknown name returns an empty schema.tables with totalTables: 0 rather than an error. Otherwise limit (default 25, max 200) and offset page the tables in name order, with schema.totalTables and hasMore/nextOffset telling you what you haven't seen.

Recoverable errors (success channel)

errorCodeMeaning
no_managed_databaseThe app has no DeployMill-managed database. Add a database block to .deploymill/project.json and reconcile_project.
describe_unsupportedThe app is a legacy one whose database is hosted by an external vendor this server can't introspect. Use that vendor's own console.
control_plane_databaseThe app is DeployMill's own control plane. Its database holds platform-wide authentication, audit and secret records, so it isn't readable through the per-app tools.

Run a read-only query: query_database

query_database runs one read-only SQL statement and returns capped rows. This is the agent's actual row-data surface. It requires the direct data access entitlement, which every paid plan has (any org with a card on file) and the free Explore tier does not — see Upgrading.

query_database({ applicationId, sql: "select id, title from todos order by created_at desc", maxRows: 100 })
{
  "ok": true,
  "applicationId": "app_123",
  "provider": "postgres",
  "columns": ["id", "title"],
  "rows": [{ "id": "…", "title": "ship it" }],
  "rowCount": 1,
  "truncated": false
}
  • Read-only is enforced by the database, not by reading your SQL. Each app

    gets a dedicated Postgres read-only role — granted CONNECT, USAGE and SELECT, and nothing else — and query_database opens its connection as that role, inside a read-only transaction with a server-enforced statement timeout. A write is refused by Postgres itself, whatever the statement looks like. There is no allowlist of read-only-looking keywords, so any statement that genuinely reads is allowed (including ones a keyword check would wrongly reject, like (SELECT …) UNION (SELECT …)), and a write comes back as not_read_only.

  • Single statement only: a ;-separated batch produces several result sets

    and this tool returns one, so a batch is refused with not_single_statement.

  • maxRows defaults to 1000 and is hard-capped at 1000. When more rows match,

    truncated: true, so narrow the query or paginate in SQL.

  • Secret-named columns are redacted: cells come back as [redacted] when the

    column name contains (case-insensitively) any of password, passwd, pwd, secret, token, api_key, apikey, private_key, access_key, client_secret, cred, credential, hash, salt, pin, cvv, ssn, mfa, or otp. It's a substring match, so user_password_hash, apikeys, and spinner all redact — over-redaction is the deliberate failure mode. Aliasing a denylisted column (select password as x) does not defeat it. Two fallbacks over-redact every column in the result rather than risk a leak: when a denylisted word appears anywhere in the SQL but no output column could be matched to it (a subquery, an expression), and when the statement serializes or casts a whole row into one innocuously-named column (row_to_json(t), to_jsonb(t), json_agg(t), row(...), t::text, CAST(t AS jsonb), …). So a query that mentions a secret column, or projects whole rows, can come back entirely [redacted] — select the columns you actually need by name to avoid it.

  • Every call is audited (the SQL text and row count, never the row data).

Recoverable errors (success channel)

errorCodeMeaning
no_managed_databaseThe app has no managed database.
control_plane_databaseThe app is DeployMill's own control plane — its database is never queryable through the per-app tools.
not_read_onlyThe database refused the statement: the connection may only read. Rewrite it as a read.
not_single_statementMore than one ;-separated statement was sent. Send one at a time.
query_unsupportedThe app is a legacy one whose externally-hosted database has no clean read-only SQL path. Use that vendor's own SQL editor.
query_failedThe database rejected the query (syntax error, write blocked by the read-only transaction, timeout, …).
upgrade_requiredThe org's plan doesn't include direct data access. Includes an upgradeGuide pointer (and upgradeUrl when configured).
data_access_disabledAn org admin turned the direct-data-access governance toggle off, even on a paid plan.

A typical flow

  1. describe_database to learn the tables and columns (free; do this first).
  2. query_database to read the rows you need (read-only, capped, audited).

From the dashboard

Everything above is also on the app's Database tab in the dashboard, for when you want to look at your data yourself rather than ask an agent:

  • Tables lists the live schema — columns, types, primary keys, indexes and

    approximate row counts — plus the database's measured size against your plan's limit. This is the describe_database view, so it's free on every plan.

  • Run a query is a read-only SQL console over query_database. Each table

    also has a Preview rows button that drops a starter query into it.

The dashboard calls the same tools an agent does, so the rules are identical: the same read-only role, the same 1000-row cap, the same redaction of secret-named columns, and the same audit entry. Nothing is available in the UI that isn't available to an agent. If your plan doesn't include direct data access, the schema browser keeps working and the console explains the gate.

Security notes

  • The app's real DATABASE_URL is the canonical "secret the agent never sees".

    None of these tools return it.

  • query_database is gated by the direct data access entitlement and can be

    turned off org-wide by an admin governance toggle. It returns coded upgrade_required / data_access_disabled rather than a silent dead-end.

  • Reads are attributable: every query_database call lands in the audit trail.