Raw SQL safely
Use sdk.sql for parameterized queries — and understand why sdk.query is walled off.
Sometimes you need SQL the CRUD layer cannot express. There is a safe way to write it and an unsafe way, and the unsafe way will not save.
Use sdk.sql
sdk.sql is a tagged template that parameterizes every interpolated value automatically:
const rows = await sdk.sql`
SELECT uid, subject, created_at
FROM tickets
WHERE status = ${status}
AND created_at >= ${since}
ORDER BY created_at DESC
LIMIT ${limit}
`;Everything in ${…} becomes a bound parameter. It is not possible to accidentally
concatenate a value into the statement, because the template literal never produces one.
Raw SQL runs inside the database, on the rows as stored, so it may join and filter on id at
full speed. What the handler returns is a different matter: a client names rows by uid, never
by id, so select uid (and a referenced row's uid, through a join) for anything that leaves.
Why sdk.query is walled off
sdk.query(sql, params) exists for cases a template cannot express — a dynamically chosen
table name, a generated IN list. It requires the sdk:query capability, and beyond that,
a handler that interpolates a value into a sdk.query string will not save. The check
happens at authoring time, not at runtime.
// Rejected at save — the string is built from a variable.
await sdk.query(`SELECT * FROM tickets WHERE status = '${status}'`);
// Fine — the value is a bound parameter.
await sdk.query("SELECT * FROM tickets WHERE status = ?", [status]);Injection and scope are different problems
Parameterizing protects you from injection. It does not widen or narrow which rows you can see — that is decided by the caller's policies, before your SQL runs. A parameterized query is still a scoped query.
Schema changes
DDL — CREATE, ALTER, DROP — needs sdk:query_ddl, which is separate from and implies
sdk:query. Changing shape at runtime is rarely the right answer; prefer the console's
table editor, which handles foreign-key ordering atomically.