database
v0.2.0Talk to PostgreSQL, MySQL, and SQLite from iii — query, execute, transactions, prepared statements, and change feeds.
- macOS: arm64 · x64
- Linux: arm64 · armv7 · x64
- Windows: arm64 · x64 · x86
skill doc
Prepare a SQL statement once, run it many times against a pinned connection
how-toWhen to use
Use the prepareStatement + runStatement pair when a single SQL string
will run repeatedly with different bind values, or when you need
session-scoped state (temp tables, SET LOCAL, transaction snapshots) to
persist across several calls. prepareStatement parses + plans the SQL
once and pins a pool connection under a UUID handle; runStatement
re-executes that handle with new params on the same connection.
| Question | Use this |
|---|---|
| Will this exact SQL run more than a handful of times? | database::prepareStatement first, then re-use. |
| Do I have a UUID handle already? | database::runStatement with new params. |
| Need session-scoped state (e.g. a Postgres advisory lock) across calls? | database::prepareStatement pins one connection. |
| Just running this SQL once? | database::query — no handle, no pool pinning. |
A live handle pins one pool connection until its TTL expires. The
default TTL is 1 hour and the cap is 24 hours; while a handle exists,
pool.max - 1 connections are available for everyone else. Hold only as
many handles as you need concurrently, and let them expire (or simply
stop calling them) when the workload ends — there is no release or
close function.
Use database::query instead when the
SQL runs only once. Use
database::transaction instead
when several statements need atomic commit/rollback semantics — handles
are not transactions; commit boundaries are defined by the SQL you run
through them.
database::prepareStatement
Inputs
{
"db": "primary", // required; key from your `databases:` config
"sql": "SELECT id, email FROM users WHERE id > ?", // required; non-empty after trim
"ttl_seconds": 3600 // optional; default 3600, capped at 86400
}db and sql are required. Empty/whitespace-only sql is rejected
with DRIVER_ERROR carrying message: "empty SQL". The guard runs
before the worker acquires a pool connection, so a typo cannot
silently leak a pinned conn.
ttl_seconds values above 86400 (24 h) are silently clamped down — no
error is returned. A background evictor sweeps expired entries every
30 s; a runStatement against an entry whose TTL elapsed between
evictor sweeps fails fast with STATEMENT_NOT_FOUND and removes the
entry as a side effect.
Outputs
{
"handle": {
"id": "550e8400-e29b-41d4-a716-446655440000", // RFC 4122 v4 UUID
"expires_at": "2026-05-19T18:30:00Z" // RFC 3339 UTC; now + ttl_seconds (clamped)
}
}handle.idis the opaque UUIDrunStatementaccepts. Stable until expiry; treat it as a session token.handle.expires_atis when the worker will stop honouring the handle. After that pointrunStatementreturnsSTATEMENT_NOT_FOUND.
database::runStatement
Inputs
{
"handle_id": "550e8400-e29b-41d4-a716-446655440000", // required; UUID returned by prepareStatement
"params": [42] // optional; positionally bound at run time
}handle_id is required and must reference a live handle. Unknown or
expired ids return STATEMENT_NOT_FOUND; the response carries the
handle_id echo so callers can correlate failures.
params follows the same JSON-to-driver coercion as
database::query. The placeholder
syntax matches the SQL given to prepareStatement (? for
sqlite/mysql, $1/$2/... for postgres).
There is no timeout_ms input on runStatement. The handle pins a
pool connection for its full TTL, and per-call timeouts would not
short-circuit the underlying network round-trip on Postgres or MySQL —
configure the per-call ceiling via the connection's session lifetime
(e.g. statement_timeout in postgresql.conf) or shorten
ttl_seconds on prepareStatement.
Outputs
{
"rows": [{ "id": 1, "email": "a@x" }], // row-of-objects, same shape as `database::query`
"row_count": 1,
"columns": [
{ "name": "id", "type_name": "INTEGER" },
{ "name": "email", "type_name": "TEXT" }
]
}The envelope is bit-for-bit identical to
database::query — same row coercion
rules, same columns[i] metadata, same empty-result handling. Callers
can share one parser for both surfaces.
runStatement does not surface write counts or last_insert_id. To
re-run an INSERT/UPDATE/DELETE many times and read those fields, use
database::execute per call — write
statements are typically cheap to re-parse and the prepared path saves
less than the pool-pinning cost.
Worked example
Prepare a paginated SELECT once, then advance the cursor twice. Same flow on every driver; the SQL changes its placeholder syntax.
SQLite or MySQL — prepare:
{
"db": "primary",
"sql": "SELECT id, body FROM outbox WHERE id > ? ORDER BY id LIMIT 50",
"ttl_seconds": 3600
}Returns
{ "handle": { "id": "550e8400-...", "expires_at": "..." } }.
Run with cursor 0 (first page):
{ "handle_id": "550e8400-...", "params": [0] }Run again with cursor 50 (second page) on the same handle:
{ "handle_id": "550e8400-...", "params": [50] }If a runStatement returns STATEMENT_NOT_FOUND mid-loop, the handle
expired (or the worker process restarted): re-prepare and continue from
the last successfully-read cursor. Do not retry the same
handle_id.
Related
database::query— drop the handle altogether for one-shot reads; same response envelope.database::execute— for repeated writes; pair its ownlast_insert_idwithaffected_rowsper call.database::transaction— group writes into an atomic batch instead of holding a pinned connection across many calls.- Error code
STATEMENT_NOT_FOUND— re-prepare and retry with the newhandle.id; the old one is gone. - Error code
POOL_TIMEOUT— too many live handles can starve the pool. Bumppool.maxin yourdatabases:config or shortenttl_seconds.