API reference · Data plane
SQL
Run one SQLite statement against the project's database, with a deliberately narrow grammar and guard rails that protect the platform columns and the access presets.
/v1/sql { query }sk_200 { data: { columns, rows, row_count, duration_ms } } — the same shape for every statement, DDL included/v1/sql/historysk_{ id, query, status, row_count, duration_ms, ts }. Never the resultsThe same two routes exist on the management plane for an account key: POST /v1/projects/:pid/sql and GET /v1/projects/:pid/sql/history. A pk_ is always refused with 401.
The grammar#
SELECT <cols|*> FROM <table> [WHERE <col> <op> <value> [AND ...]] [ORDER BY <col> [ASC|DESC]] [LIMIT <n>]
INSERT INTO <table> (<cols>) VALUES (<values>) [, (<values>) ...]
UPDATE <table> SET <col> = <value> [, ...] [WHERE ...]
DELETE FROM <table> [WHERE ...]
CREATE TABLE <table> (<col> <TEXT|INTEGER|REAL|BOOLEAN|JSON>, ...)
ALTER TABLE <table> ADD COLUMN <col> <type>
ALTER TABLE <table> DROP COLUMN <col>
ALTER TABLE <table> RENAME COLUMN <col> TO <col>
DROP TABLE <table>- One statement per request. No
OR, no join, no aggregate, no subquery. - At most 40
ANDconditions in aWHERE, at most 40 assignments in aSET;VALUEShas no cap on tuples. The database engine accepts 100 bound parameters per statement, and these ceilings are what fits. - Column types:
TEXT,INTEGER,REAL,BOOLEAN,JSON. This is SQLite — noSERIAL,jsonb,timestamptzornow(). - A
CREATE TABLEnever declares the five platform columns: the platform adds them. A table created here starts on theprivepreset; change it withPATCH /v1/tables/:tableor theset_table_presettool.
Guard rails#
- The platform columns (
id,owner_id,space_id,created_at,updated_at) cannot be dropped, renamed, retyped or written →403 forbidden. - The reserved prefixes
_mb_,sqlite_and_cf_cannot be read, altered or created, whatever the key →403 forbidden. - After every successful DDL the schema is rescanned. A table with no declared preset is forced to
prive, and a warning is written in the request log. - Guard rails are applied to the parsed statement — the unquoted table name, the statement type — never to its text. Case, quoting, backticks or comments do not get around them.
Destructive statements confirm in two steps#
A statement is destructive when it does not name its target: DROP TABLE, ALTER TABLE … DROP COLUMN, ALTER TABLE … RENAME COLUMN, and a DELETE or UPDATE whose WHERE carries no equality on id. Row-by-row CRUD through the rows routes is never destructive.
The first call answers 428 confirmation_required with will_destroy (what would happen) and a confirmation_token; nothing has run. The second call, same query plus the confirmation_token in the body, executes. The token is single-use, lives 5 minutes, and is bound to the account, the project, the key class, the computed effect and the state of the target — if the table changed in between, the API answers 428 again with a fresh token.
An sk_ confirms itself: it is the project's own server. An ak_ must also present X-Account-Session: without a signed-in human it receives 401, and no token is issued. The reason is written in The human boundary.
History#
The history keeps the text of the last 50 statements, their status, row count, duration and timestamp — never their results. Two things follow. The query text carries the literals of your INSERT and UPDATE statements (e-mail addresses, tokens, business identifiers), so the history is not a confidentiality boundary. And the history is the project's, not a key's: sk_ and ak_ see the same 50 entries. It is bounded to 50 entries and 30 days, purged when a statement is written.
Idempotency#
POST /v1/sql deliberately ignores Idempotency-Key: re-running a statement is the wanted behaviour, and an INSERT replayed must insert again.
