Stored procedures

A procedure is a statement list stored in the catalog and run by CALL. A fixed sequence of statements then costs one round trip instead of N, and the sequence lives with the schema rather than being copy-pasted into every client.

CREATE PROCEDURE archive_order(order_id TEXT)
BEGIN
  INSERT INTO archive (id, at) VALUES (order_id, now());
  DELETE FROM orders WHERE id = order_id;
  SELECT id, at FROM archive WHERE id = order_id;
END;

CALL archive_order('o-1');     -- returns the last statement's rows
DROP PROCEDURE [IF EXISTS] archive_order;
SHOW PROCEDURES;               -- name, params, definer, created

The whole surface is ordinary SQL, so every driver works unmodified — there is no new wire opcode and no new result shape. CALL p(?, ?) prepares like any other statement, so arguments bind through the driver's normal parameter path.

The body

A body is a ;-terminated list of steps between BEGIN and END. Without a RETURN, the last statement's result is the call's result; intermediate result sets are discarded, so a body that reads a million rows and then selects one returns one row.

Every statement needs its ;, including the last one. Without a terminator, SELECT a FROM t END reads END as a column alias and swallows the body's terminator. Block and loop terminators (END IF, END WHILE, END FOR) close their own construct and a trailing ; after them is optional.

What a body may contain:

Allowed Refused
SELECT, INSERT, UPDATE, DELETE DDL of any kind
CALL of another procedure BEGIN / COMMIT / ROLLBACK
Dynamic SQL (there is none — no EXECUTE '<string>')

Each refusal is a decision, not a gap:

  • No DDL. A table that does not exist when the procedure is created cannot be resolved, which would make the privilege footprint below unknowable — and a scheduled body (a later phase) would put a cluster-wide DDL round on the wire on every run.
  • No transaction control. A procedure is not a transaction. It may run inside BEGIN ATOMIC PARTITION, where its writes buffer and commit with everything else in the partition; it may not open one itself.
  • No dynamic SQL. A string is not analysable, so a body that built one would have no knowable footprint at all. This is the reason EXECUTE '<string>' does not exist and will not.

Control flow

CREATE PROCEDURE reconcile(cutoff INT)
BEGIN
  DECLARE n INT DEFAULT 0;
  DECLARE label TEXT;

  SELECT count(*) INTO n FROM orders WHERE total > cutoff;

  IF n = 0 THEN
    RETURN {matched: 0, label: 'none'};
  ELSEIF n > 100 THEN
    SET label = 'many';
  ELSE
    SET label = 'few';
  END IF;

  FOR r IN (SELECT id, total FROM orders WHERE total > cutoff) DO
    INSERT INTO flagged (id, total) VALUES (r.id, r.total);
  END FOR;

  RETURN {matched: n, label: label};
END;
Construct Notes
DECLARE v <TYPE> [DEFAULT <expr>] Scoped to the enclosing block; starts NULL without a DEFAULT. Redeclaring in the same block is an error.
SET v = <expr> The variable must be declared — assigning an unknown name is an error, not a silent NULL.
SELECT … INTO v1, v2 FROM … The first row's columns, positionally. Sets FOUND; with no row the targets become NULL.
IF … THEN … [ELSEIF … THEN …] [ELSE …] END IF Only a true condition takes a branch: a NULL condition behaves like false, as in a WHERE.
WHILE <cond> DO … END WHILE See termination below.
FOR v IN (<query>) DO … END FOR Each row is bound to v as a document, read as v.column.
LEAVE Exits the innermost WHILE/FOR.
RETURN [<value>] Stops the body. See What a call returns.
BEGIN … END A nested block: a scope, and the only place a handler attaches.

Variables substitute into statements exactly as parameters do, so the same shadowing rule applies — DECLARE id INT next to WHERE id = id is refused for the same reason and with the same message.

What a call returns

  • RETURN {k: <expr>, …} — one row, one column per field, in written order. The fields are expressions, unlike a plain {…} literal elsewhere in SQL, which is constant-only.
  • RETURN <expr> — one row, one column named result.
  • RETURN with no value, or falling off the end — the last statement's result set.

Termination

There is no iteration cap. A WHILE is bounded by the caller's scan budget and deadline, which is the language's only termination guarantee and the reason WHILE 1 = 1 cannot run forever: the body inherits one ceiling for the whole call, and every row any statement examines counts against it.

That guarantee is why a handler cannot catch a resource limit (below), and why a scheduled job — which has no caller to inherit from — will have to install a budget and deadline explicitly.

Exception handlers

A block may end with handlers, which is what lets a body swallow one bad row and carry on instead of stalling:

BEGIN
  INSERT INTO t (id, email) VALUES (k, mail);
EXCEPTION
  WHEN UNIQUE_VIOLATION THEN
    INSERT INTO rejects (id, why) VALUES (k, error_message);
  WHEN OTHERS THEN
    INSERT INTO rejects (id, why) VALUES (k, error_message);
END;

Inside a handler, error_kind and error_message are bound to what was caught. Conditions:

Condition Catches
NOT_FOUND a table, index or database that does not exist
CONSTRAINT constraint violations, "already exists"
UNIQUE_VIOLATION a UNIQUE index collision
FOREIGN_KEY_VIOLATION a child row without its parent, or a RESTRICT parent write
NOT_NULL_VIOLATION a row that lands without a NOT NULL column (absent or NULL)
CHECK_VIOLATION a row for which a CHECK expression is false
TYPE_ERROR type errors
UNSUPPORTED unsupported statements and shapes
OTHERS any of the above

What no handler can catch, and why it matters:

  • Resource limits — the scan budget, the statement deadline, KILL QUERY, and the call-depth cap. A body that could catch its own timeout could loop forever inside the handler, which would take away the only termination guarantee the language has.
  • Storage, I/O and cluster errors. A peer being unreachable is exactly the failure a handler is tempted to swallow, and swallowing it turns "I could not read that shard" into "there was nothing there" — a silent wrong answer rather than an error.

Cursors

A cursor sweeps a table in constant memory, which is what FOR … IN cannot do:

DECLARE c CURSOR FOR SELECT id, total FROM orders WHERE total > 100;
DECLARE k TEXT;
OPEN c;
FETCH c INTO k;
WHILE FOUND DO
  -- …work on k…
  FETCH c INTO k;
END WHILE;
CLOSE c;

FOUND is set by every FETCH (and by SELECT … INTO), which is the loop idiom above. Fetching from a cursor that is not open is an error.

A cursor is a resumable keyset scan over the primary key, not a snapshot. It resumes by position, so rows written while it is open are visible to it, exactly as re-running the query would be. Nothing pins a read view: holding one for the life of a body is memory this database will not hold.

That decision has consequences worth knowing before you rely on one:

  • The query must be a plain SELECT of columns from one table with an optional WHERE, on a single-column primary key — no join, grouping, ordering, limit or set operation. Anything else is refused with the reason.
  • Resuming needs a read path that can push the cursor into its walk. On a cluster that means consistency ONE on a fully-replicated table; otherwise the second page refuses loudly rather than restarting from the top, because a cursor that silently re-serves page one would loop a body forever.

FOR … IN is the convenient alternative: it materializes the query's rows, bounded by the same byte budget as the identical SELECT typed by hand, so it cannot run away — but for a sweep larger than that budget, use a cursor.

Calling another procedure

A body may CALL another procedure. The callee must exist when the caller is created, and its own footprint is folded into the caller's — see Privileges below, where the call graph is walked rather than snapshotted.

Recursion is bounded by a depth cap of 16 nested calls, not by a rule against cycles. A cycle cannot be refused at creation: either half can be dropped and recreated after the other was checked. Exceeding the cap aborts the run with a resource-limit error, which — like a scan budget or a deadline — a body cannot catch, so it cannot recurse again from inside its own handler.

Errors name the statement that failed, because a body is a single CALL to the client:

procedure archive_order, statement 2: no such table: shipments

Parameters

Each parameter declares a type:

Type Accepts Also written
TEXT strings, UUIDs STRING, VARCHAR
INT integers INTEGER, BIGINT
FLOAT floats, decimals, integers DOUBLE, REAL
BOOL booleans BOOLEAN
TIMESTAMP timestamps, epoch-ms integers
JSON documents DOCUMENT
ARRAY arrays
ANY anything

The store is schema-less, so a type is a contract on the call and nothing else: it is checked against the argument and never against storage. NULL satisfies any type — it is the absence of a value, not a wrong one — and ANY opts out.

Arguments must be constant. A call site has no row in scope, so a column reference could only ever read NULL; it is refused by name instead. Constant expressions do work:

CALL sweep_expired(now());

A parameter shadows an unqualified column

Inside a body, every unqualified reference to a parameter's name becomes the argument. That is what makes WHERE order_id = order_id_param read naturally — and it is also a trap when the names collide:

-- REFUSED: `id` is both the parameter and the column
CREATE PROCEDURE archive(id TEXT)
BEGIN
  DELETE FROM orders WHERE id = id;   -- would become 'o-1' = 'o-1' — every row
END;

skaidb refuses this at CREATE PROCEDURE whenever the column is declared — a primary-key column, a series key, or an indexed path — which is what a predicate is usually written on. Two ways out, both in the error message:

  • rename the parameter (p_id, order_id), or
  • qualify the column: orders.id is never substituted, because only unqualified names are.

Columns that are not declared anywhere cannot be detected in a schema-less store, so prefer parameter names that cannot collide.

Which database a body belongs to

Unqualified names in a body resolve against the database of the session that created the procedure, and are stored qualified. A procedure created in shop reads shop.orders no matter who calls it — otherwise the same procedure would mean different things per caller.

Procedure names themselves are never db.-qualified (like index names): a procedure lives in one database, and SHOW PROCEDURES lists the current one's.

Privileges

CALL runs with invoker rights. Two checks, both required:

  1. EXECUTE on the procedure.
  2. Every privilege the body's own statements need.

The second is the important one — without it, GRANT EXECUTE on a procedure that deletes rows would hand a read-only role a delete. Each procedure records, at CREATE PROCEDURE, the privilege its own statements need on each canonically-resolved table. A CALL checks the union over the whole call graph: what a nested body does, the caller must be allowed to do, or a thin wrapper would be a way to borrow privileges.

The graph is walked at call time rather than snapshotted at creation, so redefining a callee changes its callers' requirements immediately — a snapshot would keep authorizing a body that no longer exists.

GRANT EXECUTE ON PROCEDURE archive_order TO app;   -- the gate
GRANT INSERT  ON archive TO app;                   -- what the body does
GRANT DELETE  ON orders  TO app;
GRANT SELECT  ON archive TO app;

A grant on the whole database covers procedures in it, exactly as it covers tables:

GRANT EXECUTE ON DATABASE app TO reporting;

Creating a procedure needs CREATE (on the procedure, or on its database); dropping one needs DROP. SHOW PROCEDURES needs no privilege — it lists names, parameters, definer and creation time, never the body, which would name tables the listing role may not read.

The definer is the role that created the procedure, recorded by the server from the authenticated connection. A DEFINER clause typed by a client is discarded, so it cannot be forged. It is informational today; scheduled jobs and triggers will run as it.

Where it runs

A CALL executes at one coordinator — whichever node received it — and its writes replicate through the ordinary write path, exactly like the same statements typed by hand. The call itself is never shipped to each replica: a body can read at a different convergence state or use now(), and re-executing it on three nodes would produce three different answers that anti-entropy could not reconcile, because anti-entropy reconciles rows, not intentions.

Consequences worth knowing:

  • A procedure is not atomic. Statement 3 can fail after statements 1 and 2 have committed. For all-or-nothing across one partition, CALL inside BEGIN ATOMIC PARTITION. There, the call is checked against every table the body could write — transitively, including nested calls — before any of it runs, so a procedure that could write outside the pinned partition is refused whole rather than leaving half its writes buffered.
  • The body inherits the caller's scan budget and deadline, as one ceiling for the whole call rather than one per statement. A body that walks too much is stopped by the same machinery that stops a runaway query, and that limit is reported as itself — a procedure cannot disguise its own budget or deadline as an ordinary body error.

Running a procedure on a schedule

CREATE JOB hourly  ON SCHEDULE EVERY '1h'       CALL sweep_expired();
CREATE JOB nightly ON SCHEDULE CRON '0 3 * * *' CALL rebuild_rollups()
  WITH (timezone = '+02:00', timeout = '5m', budget = 2000000, ttl = '7d');

SHOW JOBS;               -- name, source, procedure, definer, owner,
                         -- next_run, last_run, last_status, last_error, failures
DROP JOB [IF EXISTS] nightly;

Both schedule forms are accepted because neither subsumes the other: EVERY is a duration since the last tick and drifts relative to the calendar, while CRON names calendar instants and is the only way to say "3am daily". Both reduce to the same stored field — the next run's absolute instant.

Option Default Meaning
timezone UTC The offset a CRON expression is evaluated at.
catchup false Run once on recovery after a missed window instead of skipping to the next tick.
timeout 5m Wall-clock ceiling for one run.
budget 1000000 Row budget for one run.
ttl 7d How long this job's run history is kept.
result_cap 4096 Byte cap on a run's stored RETURN document.

timeout and budget are not tuning knobs. A CALL inherits its caller's scan budget and deadline, which is what bounds a body; a scheduled run has no caller, and an unset budget means unbounded. So a job always runs with both installed — these options only choose the numbers.

Timezones are fixed offsets

UTC (the default) and +HH:MM / -HH:MM are accepted; a named zone like Europe/Warsaw is refused. skaidb embeds no timezone database, and a cron job that silently ran an hour off for half the year would be worse than one that refuses the spelling. A fixed offset has no DST transitions, so the skipped-hour and repeated-hour ambiguities do not arise.

Cron syntax

Five fields — minute hour day-of-month month day-of-week — with *, a, a-b, a,b, */n and a-b/n, plus three-letter month and day names (jan, mon). Sunday is 0 or 7. There is no seconds field: anything finer than a minute is what EVERY is for.

When both day-of-month and day-of-week are restricted, a day matching either fires (0 3 1 * mon = the 1st and every Monday) — the classic cron rule. When only one is restricted, only that one decides.

A schedule that can never fire (0 0 30 2 * — February 30th) is refused at CREATE JOB rather than silently never running.

Which node runs it

Pick with the ring, be correct with a lease. At RF=3 every replica sees every job, so the naive scheduler would fire everything three times. The ring's preferred owner keeps a job on one node with no coordination and rebalances for free when membership changes; a linearizable CAS lease on the job's state row is what makes ownership correct in the window where two nodes can both believe they own a key. The lease expires, so an owner that dies is replaced with no operator action.

The guarantee is at-least-once. An owner can run a body and die before recording it, so the same tick can execute twice — see writing a job body below, which is how you make that harmless.

The scheduler wakes every 5 seconds, so a schedule finer than that fires at the tick rate, not the rate you asked for. EVERY '500ms' is not an error; it just runs about every 5 seconds and records the ticks it skipped.

A job under memory or disk pressure is deferred: the run does not start and the tick is retried, rather than being recorded as complete. Deferral is invisible in the job's own state — a shed job looks like a healthy quiet one — so it is counted in skaidb_jobs_deferred_total.

Writing a job body

At-least-once means a body that inserts can insert twice. Four values make that survivable, and they are ordinary zero-argument calls inside the body or in the job's CALL arguments:

SCHEDULED_AT() the logical tick, identical across every attempt of the same run. Key your writes on it (INSERT … ON CONFLICT) and a retry overwrites instead of duplicating.
CURRENT_RUN() a stable id for this attempt — for correlating diagnostics with _job_runs. Not an idempotency key: a retry is a new run.
LAST_SUCCESS() finished_at of the last ok run, or NULL.
PREVIOUS_RESULT() that run's RETURN document.

The last two are what make "process everything since last time" expressible without the body maintaining its own watermark table — and the watermark is then visible to an operator in _job_runs instead of hidden in user data:

CREATE PROCEDURE roll(since TIMESTAMP)
BEGIN
  DECLARE high TIMESTAMP;
  SELECT max(ts) INTO high FROM events WHERE ts > since;
  INSERT INTO rollup (id, n)
    SELECT … ;                      -- your aggregation
  RETURN {watermark: high};
END;

CREATE JOB roll_up ON SCHEDULE EVERY '5m' CALL roll(LAST_SUCCESS());

A RETURN document becomes the run's result (capped by result_cap, and flagged rather than silently truncated when it does not fit) and the next run's PREVIOUS_RESULT().

What a run recorded

_job_runs carries the outcome, timings, the node, the RETURN document — and what the run actually did, because "it succeeded in 40 ms" does not answer what did last night's run change?, which on a schedule is the only question anybody asks:

SELECT scheduled_at, status, effects, tables_written FROM _job_runs
  WHERE job = 'roll_up' ORDER BY id DESC LIMIT 5;
effects: [ {"stmt": 3, "executions": 1,    "rows": 1240, "ms": 812},
           {"stmt": 5, "executions": 1240, "rows": 1240, "ms": 3100} ]
tables_written: ["rollup"]

effects is keyed by statement site — the position in the code — not by execution. A WHILE loop running ten thousand statements yields one entry per site, so a run record is bounded by the length of the body rather than by the volume of data it touched. That distinction is what keeps a busy job's history from dwarfing its output.

Bounded by the code is still not bounded small, so the array keeps the heaviest sites by time and rows and collapses the rest into one "+rest" entry carrying their totals. It says so rather than silently ending — a truncated list with no marker would read as "the body only did this".

tables_written is what the run actually changed, as opposed to the static footprint, which is what it could.

For a trigger, the whole batch shares one record, and the effects accumulate across it: 256 events still produce one entry per statement site.

Failure, retry and history

A failed run is recorded, counted in failures, and its message kept in last_error — a stalled job is visible in SHOW JOBS and in metrics, never inferred from absence. Retries back off exponentially (capped at an hour), so a broken job stops hammering its own history.

A node down across several windows skips to the present by default and records how many ticks it skipped; WITH (catchup = true) runs the oldest missed tick instead. Either way the decision is recorded rather than silent.

History is best-effort: a node that dies mid-body may leave no record. The lease, not the history, is what recovers the job.

A job runs as its definer

A schedule has no invoker, so a job records the role that created it and runs as that role. The server stamps it from the authenticated connection, so a role can only create a job that runs as itself — there is no spelling a client can use to run one as anybody else. This is the privilege-escalation surface of the whole feature, which is why it is decided by the server rather than by anything in the statement.

CREATE JOB therefore requires everything a CALL of the same procedure would, checked at declaration — and re-checked before every run, so a privilege revoked afterwards stops the job rather than leaving it running with rights its definer no longer has. A run refused that way is recorded as a failure, with the reason.

The job tables

Two ordinary replicated tables, created with the first job in a database:

Table Holds
_job_state one row per job: owner, lease, next/last run, last status and error, consecutive failures
_job_runs one row per run, TTL'd by the job's ttl

They are ordinary tables on purpose — that is what buys replication, retention, SQL reads and paged replay — and read-only to clients, for the same reason a stream's log is: a hand-written row is indistinguishable from a real one, and dropping _job_state would take the scheduler down. A stream over either is refused too, since it would capture its own writes.

DROP JOB leaves the tables and the job's history behind: "what did it do before I dropped it?" is the next question an operator asks.

Because _job_runs is a TTL table, its ttl bounds what you see; the space comes back on the background reclaim sweep (see QUERY_SYNTAX.md). Size it for the retention plus about one sweep interval.

Running a procedure when a row changes

CREATE TRIGGER big_order ON orders WHEN (total > 1000)
  CALL archive_order(NEW.id, OLD.total);

SHOW TRIGGERS;             -- name, table, predicate, procedure, definer,
                           -- stream, cursor, last_status, last_error, failures
DROP TRIGGER [IF EXISTS] big_order;

A trigger is a job whose source is a change stream instead of a schedule. There is one concept underneath, which is why ownership, the lease, run history and failure handling are identical to a scheduled job — only "what is due" differs.

CREATE TRIGGER creates two things and owns both: a hidden stream (__trg_<name>) carrying pre-images, and a job that consumes it. DROP TRIGGER removes both — leaving the stream would keep doubling the source table's write volume for a consumer that no longer exists.

The body sees NEW and OLD

NEW the row as it now is. NEW.total, NEW.id, …
OLD the row as it was, or NULL for an insert. Test OLD IS NULL to tell "new row" from "changed row".
EVENT() the event's id — the identity to key idempotent writes on here.
OP put, exit (matched before, no longer does) or delete.

Both come from the log, not from a re-read: the row may have changed again since, and a trigger must act on the change it was told about. SCHEDULED_AT() is NULL in a trigger — there is no tick.

It fires from the log, never on the write path

A matching write appends an event; a background consumer reads the log forward from its cursor and runs the body once per event. Inline firing is refused for three reasons, each already paid for once elsewhere:

  1. User code inside the write/applier path is a deadlock class.
  2. Capture must never fail the write. A trigger that errors cannot roll the write back — leaderless, already committed, three replicas — so an inline trigger could only choose between misreporting a committed write to the client and being ignored.
  3. Background work can be shed under load. Work welded into the write path cannot.

Firing from the log also gives retry and crash recovery for free, and means a trigger inherits everything the stream already does: the predicate is re-checked against every committed write, a write that lost LWW never fires, and a repair pass cannot replay ancient history as fresh triggers.

Delivery, failure and cost

  • At-least-once, per event. Key writes on EVENT(), which is minted from the change and identical on every replica.
  • One run record per BATCH, never per event — a record per fired trigger would re-double write volume on a hot table on top of the stream's own doubling. Always one on error.
  • A poison event does not stall the trigger. It is retried a few times, then skipped, counted and recorded. A cursor that never moves is a trigger that has stopped without saying so; SHOW TRIGGERS shows the cursor, last_error and failures.
  • A trigger costs writes. The stream roughly doubles the write volume of what it matches, and carrying pre-images doubles that again. SHOW TRIGGERS names the stream so you can measure it (SELECT count(*) FROM _stream___trg_big). Narrow the WHEN.

Cascades are refused, not capped

A trigger's writes produce events that can fire triggers. A cycle among them never terminates, so it is refused at CREATE TRIGGER:

trigger loop_self would cascade forever: its body writes 'a', which leads
back to 'a', the table it fires on.

The check walks the static write footprint of each trigger's procedure — transitively, through nested calls — across the trigger graph. A cascade without a cycle is a DAG and terminates by construction, so this bounds exactly what a runtime depth cap would, at declaration rather than at 3am.

(A runtime cap was the RFC's first shape. It needs a depth counter carried on the event to survive the consumer, and every replica emits its own copy of an event independently — so the stored depth would be whichever replica's write won last-writer-wins, and a cap built on it would fire or not fire at random.)

From an application

CALL is an ordinary statement over the existing protocol, so there is nothing driver-specific to install and no new result shape to handle: the arguments bind through the same prepared-statement path as any other placeholder, and the rows arrive through the same cursor. Placeholders differ per driver (? everywhere except Node.js and Ruby, which use $1) — see the matrix in HOWDOI.md.

SQL

CALL archive_order(?);

Python

cur = conn.cursor()
cur.execute("CALL archive_order(?)", ("o-1",))
for id_, at in cur.fetchall():
    print(id_, at)

Node.js

const res = await client.query('CALL archive_order($1)', ['o-1']);
for (const row of res.rows) console.log(row.id, row.at);

Go

rows, err := db.Query("CALL archive_order(?)", "o-1")
defer rows.Close()
for rows.Next() {
    var id string
    var at time.Time
    rows.Scan(&id, &at)
}

Java

Skaidb.ResultSet rs = conn.prepare("CALL archive_order(?)")
    .setString(1, "o-1")
    .executeQuery();
while (rs.next())
    System.out.println(rs.getString("id") + " " + (java.time.Instant) rs.getObject("at"));

Ruby

res = conn.exec_params("CALL archive_order($1)", ["o-1"])
res.each { |row| puts "#{row['id']} #{row['at']}" }

PHP

$stmt = $db->prepare('CALL archive_order(?)');
$stmt->execute(['o-1']);
foreach ($stmt->fetchAll() as $row) { echo $row['id'], ' ', $row['at'], PHP_EOL; }

C#

using var cmd = conn.CreateCommand();
cmd.CommandText = "CALL archive_order(?)";
cmd.Parameters.Add("o-1");
using var reader = cmd.ExecuteReader();
while (reader.Read()) Console.WriteLine($"{reader.GetString(0)} {reader.GetDateTimeOffset(1)}");

Rust

use skaidb_proto::Response;
use skaidb_types::Value;
let mut q = client.prepare("CALL archive_order(?)")?;
if let Response::Rows { rows, .. } =
    client.execute_prepared(&mut q, &[Value::String("o-1".into())])?
{
    for row in rows { println!("{} {}", row[0], row[1]); }
}

A call returns one result set, or none

What comes back is what the body returned — a RETURN, or the last statement's result set. Two consequences are worth knowing before you write the client half:

  • Only the last result survives. A body that runs five SELECTs hands back the fifth. There are no multiple result sets, so no driver needs a nextResult() and none of them have one.
  • A body ending in a mutation returns no rows at all. The call still succeeds and the affected-row count is still reported through the driver's ordinary channel (rowcount, RowsAffected(), executeUpdate()), but there is no result set and no column metadata — cur.description is None rather than an empty list. Iterating it is not an error; it simply yields nothing. If a caller needs data back, end the body with a SELECT or a RETURN.

Watching a job or a trigger

A scheduled or triggered run has no client to return to, so there is nothing for a driver to fetch: the run's outcome is recorded instead, and you read it with an ordinary SELECT through the same query call as anything else.

SHOW JOBS;    -- name, source, procedure, definer, owner, next_run,
              -- last_run, last_status, last_error, failures

SELECT job, status, scheduled_at, started_at, finished_at, node,
       error, effects, tables_written
FROM _job_runs ORDER BY id DESC LIMIT 20;

Those are all of a run's columns: finished_at - started_at is how long it took, and scheduled_at is the logical tick it was for — which is not the same instant, and the gap between them is how late the run was. Two fields are written only when they apply: error on a failure, and missed when ticks were skipped. The store is schemaless, so selecting one that a given row does not carry yields NULL rather than an error — a successful run reads back error = NULL, and a misspelled column name reads back NULL too, which is worth knowing before you trust an empty result.

SHOW JOBS is the live view — where a job is now and when it fires next. _job_runs is the history, newest last by key order, and it is where a failure's error and a run's effects live. Both are read-only to clients: the scheduler owns them, so a client can SELECT them and nothing else.

The columns arrive typed, so started_at is a timestamp in the driver's native type, and effects is a list of objects — one per statement site that did work, which a driver hands back as its ordinary nested-document type (a list of dict in Python, []any in Go):

[{"stmt": 1, "executions": 1, "rows": 1, "ms": 0}]

A trigger records its runs in exactly the same place under its own job name, so one query covers both.

Limits today

No OUT parameters and no multiple result sets — both need a protocol change, so a call returns exactly one result. No dynamic SQL, and no user-defined functions: a procedure is reachable by CALL only, never from inside an expression, so it can never end up in a WHERE clause the planner would have to encode for a peer.

RETURN is scoped to the modes where a run IS one execution — manual and scheduled: a trigger run is a batch of N executions, so a single result per run would have no honest meaning. A trigger body does not need one, because its state is the cursor.

Jobs and triggers run on a standalone server too, with the same policy: one node always owns the lease, and there is no ring to pick from.