Route: /p/:id/sql. Source: apps/studio/src/features/sql, backed by apps/server/services/admin/sql.service.ts.
The workspace
| Component | Role |
|---|---|
SqlWorkspace | Editor, run action, result grid |
PaneTab | Multiple query panes |
SqlLibraryPanel | Saved snippets |
Cell | Result cell rendering |
Two calls:
GET /admin/schema/tables[?schema=…] schema browser
POST /admin/sql { sql } runEnabling it
Two switches, read together as a SqlMode:
| Switch | Where | Effect |
|---|---|---|
CONSOLE_SQL_ENABLED | environment | false → every run returns 403 SQL console is disabled |
Allow writes from the SQL editor (database.sqlEditor) | Database → Settings → SQL editor | on (default) → the editor runs anything; off → only read statements are accepted |
Projects created by the Console ship with CONSOLE_SQL_ENABLED=true, and writes are allowed until someone turns
them off. When they are off, a Read-only badge next to Run links to the setting. Turning it off or on needs
the settings.write capability, so a developer who can run SQL cannot grant themselves writes.
The setting replaces the former CONSOLE_SQL_READONLY environment variable, which is no longer read.
What counts as a read
classifyStatement strips block and line comments, then looks at the first keyword. SELECT, WITH, EXPLAIN, SHOW, VALUES and TABLE are reads; everything else is a write.
That alone is not enough, so isDisguisedWrite rejects the classic bypasses on top:
-- rejected in read-only mode
WITH gone AS (DELETE FROM todos RETURNING *) SELECT * FROM gone;
EXPLAIN ANALYZE INSERT INTO todos …;The rule: a leading SELECT/SHOW/VALUES/TABLE is safe, but a WITH or EXPLAIN statement is scanned for INSERT|UPDATE|DELETE|MERGE|TRUNCATE|DROP|ALTER|CREATE|GRANT|REVOKE|COPY|CALL|DO|REINDEX|VACUUM|REFRESH|SET|RESET|LOCK|SELECT … INTO, and EXPLAIN ANALYZE is refused outright because it executes the plan. String literals and quoted identifiers are stripped before that scan, so a row containing the word delete does not trip it.
Batches are split on ; and every statement is checked — one write in a batch of reads fails the whole batch with 403 Only read statements are allowed: the SQL editor is read-only.
In read-only mode the batch also runs inside a Postgres READ ONLY transaction, so a write that slips past the
static check is refused by the database itself. The AI assistant's queries always take that path
(/api/admin/sql/readonly), whatever the setting says — see AI assistant. Either way
the editor connects as the owner role: for a genuinely unprivileged path, give people a role without write grants.
After a write
Successful DDL runs through schema-ddl.ts and invalidates the REST catalog (services/rest-api/catalog/), so a table created here is immediately visible to the auto-generated REST API without a restart.
The response carries wrote: true when any statement of the batch wrote, even if the last one was a read. Studio
then marks every cached screen stale, so a function, type, index, policy, role, grant, publication or row created
here shows up on its page without a reload. The AI assistant does the same after a turn that ran tools.
Audit
Every statement is written to the shared log buffer with source: 'sql' (SqlService.audit), which is why SQL activity appears in Logs even though the request logger deliberately skips /api/admin/*. See Observability.