Building apps

Databases and saved queries

Reading your warehouse through saved queries that an admin publishes, and when direct SQL is the right call.

Prefer saved queries

A saved query is a named, versioned SQL template that an org admin publishes against one data connection. Apps and members invoke it by name with typed, named params, so the app never embeds SQL.

await savedQueries();                        // [{ name, description, params }]
await query("revenue_by_month", { year: 2026 });

Manifest: saved_queries: [revenue_by_month].

Templates use :name placeholders that match declared params. Each param is typed string, int, float, or bool. A param declared with a default is optional at invoke time.

Per-caller rows

A saved query does not know who ran it. The caller's identity decides the grant check and the audit log, but it is never bound into the SQL. The old context binds (:_ctx_user_id, :_ctx_user_email, :_ctx_org) were removed. A placeholder or param named _ctx* is rejected with _ctx binds are no longer supported.

To scope rows per caller, declare a typed param and pass the value:

where rep_email = :rep_email
await query("my_deals", { rep_email: repEmail });

Anyone who can run the query can pass any value. If some callers must never see other rows, publish separate queries and grant each one only to the people who should run it.

Direct SQL

Only when someone explicitly asks for it. Always bind parameters:

await data("warehouse").runSQL("select * from orders where id = $1", [id]);
await postgres("warehouse").runSQL(...);     // dialect-pinned variants
await bigquery("analytics").runSQL(...);
await turso("edge").runSQL(...);

Manifest: adhoc_sql: [warehouse].

adhoc_sql grants raw SQL authority and is intentionally scarce. Do not declare it unless someone asked for direct SQL. A saved query is almost always the better answer.

Read-only access is enforced with read-only sessions on Postgres and Turso. BigQuery has no session-level read-only mode, so the privileges of its connection credential are the boundary.

Discovering what exists

Before you design around an integration, look at what the org already has. These commands are org-scoped: they work right after railcode login, with no app and no railcode.json.

railcode db list                    # data connections: name + engine
railcode query list                 # saved queries: name, params, version, description
railcode connector list             # connectors you can reach

query list returns signatures only, never SQL text.

An empty local result does not prove that a source is unsupported. If you cannot reach the instance, ask an admin what is configured.

Running SQL from the terminal

railcode db query "select 1"
railcode db query "select * from orders where total > $1" --params '[100]'
railcode db query --file report.sql --connection analytics

railcode query run my_orders --params '{"region":"emea","limit":5}'

Note the two different --params shapes. db query takes a positional array ('[100]' binds $1). query run takes a named object, exactly matching the SDK call.

Reading the org directory

await appUsers();          // [{ id, email, name, is_admin }]; id matches ctx.user.id
await dataConnectors();    // [{ name, engine }], never a DSN

Both are read-only discovery, for building pickers and showing names.

Publishing a saved query

Creating, updating, and deleting saved queries is organization administration. See administration.

On this page