Database¶
WebCortex ships a deliberately small data layer. Understanding what it is not matters as much as what it is.
Configuration¶
Overridable at runtime with WEBCORTEX_DATABASE_URL, so the same image runs in
every environment.
SQLite today; Postgres next
v2 supports SQLite. The Query op assumes ? placeholders and SQLite's
RETURNING semantics; Postgres needs a small dialect layer and is the next
milestone. For a Postgres-backed app today, use a Python handler with
asyncpg or SQLAlchemy — that path works, it just runs in the interpreter.
Declaring tables¶
app.resource("tickets",
fields={"id": int, "subject": str, "priority": int},
read_scopes=["read"], write_scopes=["write"])
Python types map to SQLite affinities:
| Python | SQLite |
|---|---|
int |
INTEGER (PRIMARY KEY AUTOINCREMENT for the key) |
float |
REAL |
str |
TEXT |
bool |
INTEGER |
bytes |
BLOB |
Migrations: deliberately absent¶
create_table=True emits CREATE TABLE IF NOT EXISTS and nothing else.
Changing fields will not alter an existing table.
This is on purpose
Silently issuing ALTER TABLE because a Python dict changed is how
frameworks destroy production data. Schema evolution must be versioned,
reviewable, and explicit.
For now, manage migrations with a tool you already trust — Alembic,
sqlite-utils, or plain SQL files under version control — and set
create_table=False.
Writing queries¶
app.query(
"GET", "/tickets/recent",
"SELECT * FROM tickets WHERE created_at > ? ORDER BY created_at DESC LIMIT COALESCE(?, 50)",
params=["since", "limit"],
returns="many",
scopes=["read"],
)
params names the value bound to each ?, in order, resolved from the path,
then query string, then JSON body.
COALESCE(?, 50) is the idiom for an optional parameter with a default: an
absent value binds as NULL and the default applies.
Return shapes¶
returns="many" # [] of row objects
returns="one" # single object, 404 if no match
returns="affected" # {"affected": n, "last_insert_id": m}
Writes that return the row¶
SQLite supports RETURNING, which is how app.resource gives you the created
row rather than just an id:
app.query("POST", "/tickets",
"INSERT INTO tickets (subject, priority) VALUES (?, ?) RETURNING *",
params=["subject", "priority"], returns="one", scopes=["write"])
Injection¶
Values are always bound, never interpolated.
$ curl "localhost:8000/tickets?limit=1%20OR%201=1"
{"error":{"status":400,"message":"the request contained a value this query cannot use"}}
That 400 is the proof: 1 OR 1=1 arrived as a string bound to a LIMIT, which
is a type error rather than an injected clause.
The SQL text itself must come from your source. Never build a query string from request data — if you find yourself wanting to, use a Python handler and a real query builder.
Client faults are 400, not 500
A bad parameter type is the caller's mistake, so it gets a 4xx. Conflating it with a server fault inflates error budgets and pages on-call for client mistakes. Detail stays in the log; the client is told only which side is at fault.
Using Python instead¶
When you need transactions, joins across services, or an ORM:
import sqlite3
@app.get("/reports/summary", scopes=["read"])
def summary() -> dict:
con = sqlite3.connect("app.db")
try:
rows = con.execute(
"SELECT status, COUNT(*) FROM tickets GROUP BY status"
).fetchall()
return {"by_status": dict(rows)}
finally:
con.close()
This runs on the Python worker pool — slower than a Query op, but it is real
Python and everything is available.
There is no WebCortex ORM, and there will not be one
SQLAlchemy is twenty years of accumulated correctness around identity maps, lazy loading, and transaction boundaries. Competing with it is a multi-year project orthogonal to everything interesting here.
app.query is not an ORM — it is a way to bind a route to SQL so Rust can
execute it. When you need object mapping, use SQLAlchemy inside a handler.
Performance notes¶
Reads sustain ~22,000 req/s. Writes are different:
| Operation | req/s | p50 | p99 |
|---|---|---|---|
SELECT |
22,317 | 0.92 ms | 2.88 ms |
INSERT |
2,461 | 2.92 ms | 119.21 ms |
That write tail is SQLite serialising writers, not the framework. It is a property of the database and a good reason to prioritise Postgres for write-heavy workloads.