You send its source and nothing else: the server
compiles it to WebAssembly and then to native code when you deploy it. No toolchain is needed where you write it,
so a function can be written and deployed from a phone through MCP. It is routed, gated, limited, counted and
logged exactly as a JavaScript function is, and it gets the same request and gives back the same kind of
response. It is written against the substrio_wasm crate (packages/substrio-wasm), which the server provides.
Wasm functions suit computational work such as parsing, encoding, hashing, scoring, and processing images or text. JavaScript is a choice for code that mostly calls the platform.
A function
#![no_std]
extern crate alloc;
use alloc::format;
use substrio_wasm::{Ctx, Env, Request, Response};
fn fetch(req: &Request, env: &Env, _ctx: &Ctx) -> Response {
let name = req.param("name").unwrap_or("world");
let greeting = env.get("GREETING").unwrap_or("Hello");
Response::text(200, &format!("{greeting}, {name}!"))
}
substrio_wasm::export!(fetch);
fetch(request, env, ctx) is the JavaScript entry's shape. export! writes the module's three exports
(substrio_in, substrio_alloc, substrio_fetch). The crate has no dependencies and brings its own allocator
(blocks up to 64 KiB in power-of-two classes, larger ones as exact runs of 64 KiB pages, so a 70 MiB body takes
about 70 MiB of the function's memory limit) and panic handler. A function is one source file (#![no_std], with alloc); it can use the crate, core and
alloc, and no other crate.
Deploy
Send the source. Over MCP or the API, function.deploy (POST /v1/deploy) takes it as text in rust in place
of script:
{ "name": "hello", "rust": "#![no_std]\nextern crate alloc;\n...\nsubstrio_wasm::export!(fetch);\n",
"routes": ["GET /hello/:name"], "vars": { "GREETING": "Hi" } }
With the CLI, put the source in functions/ with its routes beside it, as for a JavaScript function, and run
substrio deploy. The file functions/hello.json holds routes and tools only. The vars of the function are
declared in substrio.toml:
functions/hello.rs
functions/hello.json { "routes": ["GET /hello/:name"] }
substrio.toml [functions.hello] vars = { GREETING = "Hi" }
The answer's function.kind is "wasm". A function keeps its name and its routes when it moves between
JavaScript and Rust. function.update takes rust the same way.
At deploy the server compiles the source in two steps, each confined in the tools jail (one process, no network, nothing of the host's but what the step needs, read-only):
rustc(from the release,rust/) compiles it with the crate into WebAssembly. Its flags are the platform (rust/ARGS, the same the crate was built with):--edition 2021,-C opt-level=3,-C codegen-units=1,-C panic=abort. It has 120 s of time and CPU and 4 GiB of memory.substrio_wasmclinks it, validates the module against the platform ABI, translates it to C and compiles that to native code for the server's CPU.
The compiled code is kept by the source's content address (GxHash-128) and the toolchain's version, so a source already compiled on a node is never compiled again: deploying it again, or to another app, answers from the cache. A new release with another toolchain compiles each function once more, on its first call.
substrio_wasmc and rust/ ship in the release beside the server; the server links neither. A release is built
on a host with LLVM 19's static libraries (Debian llvm-19-dev, libclang-19-dev, liblld-19-dev and
libpolly-19-dev) and the one Rust toolchain the release pins, a dated nightly with its rust-src component
(SUBSTRIO_RUST_TOOLCHAIN in scripts/release/keys.sh: rustup toolchain install nightly-2026-10-20 --component rust-src). A function is compiled for wasm64-unknown-unknown, which has no prebuilt standard library, so
scripts/release/rust.sh builds core and alloc for it from that source and bundles them with the compiler and
the C library it runs on, once per toolchain version.
A function's memory is 64-bit (memory64), so the app's store fits in its address space (below). Each instance
reserves an address space of 2^41 bytes (SUBSTRIO_WASM_SPACE_BITS, set when the release is built), which is
virtual and not memory: a 5-level-paging host holds about 32,000 of them. The store is mapped from 64 GiB upward in that
space, so about 1.94 TiB of an app's store can be read directly. A module compiled for another space, or for the
32-bit ABI before it, is refused when it is loaded and compiled again from its source before it runs.
Edit in place
An agent edits a Rust function as it edits a JavaScript one: function.source.read to read it,
function.source.preview then function.source.edit for a range of lines or bytes, function.source.replace for
the whole source (in rust: a replace keeps the function's language, and script on a Rust function is refused
with 400). Every one compiles the edited source exactly as a deploy does, before anything is stored: an edit that
does not compile is refused with RUST_COMPILE_FAILED and rustc's diagnostics (a preview already refuses it), and
the function keeps serving its last source. An edit that compiles serves from the next call:
POST /v1/functions/hello/source/preview { "start_line": 4, "end_line": 5, "replacement": " Response::text(200, \"hi\")\n" }
PATCH /v1/functions/hello/source { "start_line": 4, "end_line": 5, "replacement": "...", "approval_token": "<the preview's>" }
The request
Request is read in place: nothing in it is parsed or copied on a call.
| Method | JavaScript | Answer |
|---|---|---|
method() |
request.method |
the method as sent, e.g. "GET" |
url() |
path and search of request.url |
"/t/echo?a=1" |
path(), query() |
url.pathname, url.search without ? |
|
header(name), header_bytes(name) |
request.headers.get(name) |
the first header of that name, any case |
headers() |
request.headers |
every header in order, as (name, value) bytes |
param(name), params() |
request.params |
a tail wildcard is "*" |
route() |
request.route |
the matched route's index among the function's routes |
body(), text() |
request.arrayBuffer(), request.text() |
None when the request had no body |
auth_json(), signed_in() |
auth.getCurrentUser() |
the caller's user as JSON |
caller_json() |
request.caller |
a verified calling sandbox, else None |
ip() |
request.ip |
on a route declared with ip only |
tenant() |
substrio.tenant |
the app's slug |
No header that carries a key and no Substrio-Caller header reaches a function, as for JavaScript.
The response
Response::new(status), Response::text(status, &str) (Content-Type text/plain;charset=UTF-8, the Fetch
standard's type for text; a JavaScript function answering new Response(text) with no Content-Type of its own is sent
as application/json on this platform, so its twin of Response::text names the type),
Response::json(status, bytes) (Content-Type application/json, as Response.json),
then .header(name, value), .content_type(value), .body(Vec<u8>) or body_mut(). The platform sends it
from the module's own memory.
env, ctx and the platform
| Rust | JavaScript | What it does |
|---|---|---|
env.get(name), env.pairs() |
env.NAME |
the app's secrets, then the function's vars; a name in both is the secret's. Fetched once and kept until a deploy or a secret changes it |
substrio_wasm::api(method, path, body) |
substrio.api(method, path, body) |
one operation from the platform registry, as the app. The result is the JSON text JavaScript parses, including error JSON on failure |
substrio_wasm::api_as(method, path, body, authorization) |
substrio.api(..., authorization) |
the same, run as the caller |
auth::current_user() |
substrio.auth.getCurrentUser() |
the caller's user as JSON |
auth::check_permission(name) |
substrio.auth.checkPermission(name) |
true when the caller holds it |
console::log(line), log!(...) |
console.log |
one line in the function's log (GET /v1/_logs?function=<name>) |
ctx is the call's context. In this release it carries nothing more than the request does.
The database
substrio_wasm::db provides the JavaScript function db API with Rust names
such as find_by_id for findById. Reads access storage directly without HTTP,
JSON, or a copy. The app's store is mapped read-only into the function's address
space, and values are read where they are stored.
use substrio_wasm::db;
let people = db::collection("people")?;
match people.find_by_id(id, |row| row.to_json())? {
Some(json) => Response::json(200, json),
None => Response::json(404, "null"),
}
A read takes a closure and returns its result. Another call can change a row
while it is being read. When the closure returns, the read verifies that each
row is unchanged and runs the closure again on the current row if it changed.
JavaScript reads perform the same check. A row and its values are valid within
the closure, so copy out anything you need to keep with String::from or
to_json. A read inside another read's closure returns INVALID_ARGUMENT.
| Rust | JavaScript | Return value |
|---|---|---|
db::collection(name) |
db.collection(name), db.<name> |
the collection. Its trees are made at its first use, as JavaScript's are |
c.find_by_id(id, |row| ...), c.get(id, |row| ...) |
findById(id), get(id) |
the closure's result over the document's row (its fields, then _id), or None. An id that is not a document's base62 id returns None without a read |
c.field(name) |
the field, resolved once, for row.field(f): no name is compared in the read |
|
c.count() |
count() |
the documents that have not expired, read in O(1) |
c.exists() |
exists() |
whether any document has not expired |
row.get("name") and row.field(f) return a Value: Null, Bool, Int, Double, Date (milliseconds),
Str, Decimal and BigInt (their text), Binary, ObjectId, Vector (a vector field's floats), Object and
Array. A text or binary value is a slice of the stored bytes and a number is read where it is stored.
Object(o).fields() and Array(a).elements() read a nested value the same way. row.fields() walks the fields
in their stored order, row.id() is the _id, and row.to_json() writes the row in the platform JSON format (a date as
its milliseconds, binary as base64).
A row contains the same fields as a JavaScript row. Fields declared secret
are excluded from both. Read a secret with db.secret from JavaScript.
Writes
A write takes a document as JSON object text and reads it from the function's
memory. Each write uses the same code as its JavaScript equivalent. Indexing,
realtime publication, quota accounting, and errors therefore follow the same
rules. As with JavaScript, a write blocks while another call has a
db.transaction open.
let people = db::collection("people")?;
let id = people.insert(r#"{"name":"Ada","visits":1}"#)?;
people.set(id.as_str(), r#"{"visits":2}"#)?;
people.delete(id.as_str())?;
| Rust | JavaScript | Return value |
|---|---|---|
c.insert(doc) |
insert(doc) |
the new document's _id as a db::Id (id.as_str()), held inline with no allocation. A doc that names _id is refused with ID_RESERVED |
c.set(id, fields) |
set(id, fields) |
true once each member of fields is set on the document, false when no document has the id. A name that starts with _ is a system field and is left alone, and one that starts with $ is refused with INVALID_ARGUMENT |
c.delete(id) |
delete(id) |
true when the document existed and is removed |
An expired document is absent to set and delete, as it is to JavaScript. Rust also supports update operators
through update(id, &ops) and Update. See writing records from Rust.
Chains
A chain starts at the collection with any chain method and ends at a terminal, as in JavaScript. The crate encodes the steps in a program in the module's memory. The terminal sends that program in one call, where it is decoded into the same plan used by JavaScript. The chain stays usable for another terminal until it is dropped.
use substrio_wasm::db::{self, Order};
let orders = db::collection("orders")?;
let page = orders.eq("status", "paid").gte("total", 100).sort("total", Order::Desc).skip(20).limit(10)
.all(|row| row.to_json())?;
let paid = orders.eq("status", "paid").count()?;
let revenue = orders.eq("status", "paid").sum("total")?;
| Rust | JavaScript |
|---|---|
eq, ne, gt, gte, lt, lte (field, value) |
eq, neq / ne, gt, gte / ge, lt, lte / le |
is_in, nin (field, &[values]) |
in, nin (in is a Rust keyword) |
has(field, present) |
exists(field, present) (exists() is the terminal) |
between(field, low, high) |
between |
starts_with, ends_with, contains, icontains, like, ilike, ieq, istarts_with (field, text) |
startsWith, endsWith, contains, icontains, like, ilike, ieq, istartsWith |
iin(field, &[texts]) |
iin |
contains_all, contains_any (field, &[values]) |
containsAll, containsAny |
size, size_gt, size_lt (field, n) |
size, sizeGt, sizeLt |
sort(field, Order::Asc), sort(field, Order::Desc) |
sort(field, 1), sort(field, -1): each a key after the ones before it, missing values last |
skip(n), limit(n) |
skip, limit |
all(|row| ...) |
all(): the closure's answers, one per row in the chain's order |
first(|row| ...) |
first(): the closure's answer over the first row, or None |
page(size, Cursor::First, total, |row| ...) |
page({size, total}): one page in the chain's order, its rows, next and prev tokens and, with total, the rows the chain matches |
page(size, Cursor::After(next), ...), page(size, Cursor::Before(prev), ...) |
page({size, after: next}), page({size, before: prev}): the page after or before a token's position. A token belongs to the chain it came from; another chain's, or a damaged one, is refused, as a page of a chain with skip is |
count(), exists() |
count() (before skip and limit), exists() |
sum, avg, min, max (field) |
the same: a Fold, Number(f64), Date(ms) (min or max of a date field), or Null (avg, min or max of no document) |
A value is a bool, an integer, an f64, a &str, a &[u8] (compared by its bytes) or an Arg (Arg::Null,
Arg::Date(ms)). The chain refuses what JavaScript's refuses, with the same code: a filter, sort or fold that names
a secret field or an operator given the wrong kind of value. A substring under 3 bytes has no trigram key and can
require a record scan; see text conditions. all and first read their rows as find_by_id does, closure and check included: if a writer
changed a row while your closure read it, the chain runs again and the closure reads every row again.
KV
db::kv(name) opens the app's KV store name, made at its first write, as db.kv(name) is in JavaScript. A key
and a value are bytes, so a &str, a String, a &[u8] or a Vec<u8> can be passed. A value is copied once,
straight into a buffer your function holds. No other copy is made, and no buffer is allocated once yours has grown
to the largest value it reads.
let views = db::kv("views")?;
views.put("home", "1")?;
let hits = views.increment("hits", 1)?; // Some(n) once the key holds a number
let home = views.with("home", |v| v.len())?; // reads the value with no Vec of your own
| Rust | JavaScript | Return value |
|---|---|---|
kv.get(key), kv.get_into(key, &mut buf) |
get(key), getBytes(key) |
the value's bytes, or None when the key is absent |
kv.with(key, |bytes| ...) |
get(key) |
the closure's answer over the value, or None |
kv.put(key, value) |
put(key, value) |
true once the value is stored |
kv.delete(key) |
delete(key) |
true when the key was there and is removed |
kv.has(key) |
has(key) |
whether the key is there |
kv.peek(key) |
peek(key) |
the stored length in bytes, or None where JavaScript answers -1 |
kv.get_i64(key), kv.get_f64(key) |
getInt(key) |
the 8-byte number the key holds, or None |
kv.put_i64(key, n), kv.put_f64(key, x) |
putInt(key, n) |
true once the number is stored |
kv.increment(key, delta) |
increment(key, delta) |
the number after the increment, or None when the key holds no number |
KV cursors
kv.cursor() opens a cursor over the store in key order. Each seek or step is one call that answers the entry it
lands on, its key and value together, where JavaScript makes three calls (the step, key() and value()). The
cursor is closed when it is dropped, and every cursor still open is closed when the call returns.
let mut cur = db::kv("sessions")?.cursor()?;
let mut entry = cur.seek_prefix("user:7:")?;
while let Some(e) = entry {
// e.key and e.value are the entry's bytes
entry = cur.next()?;
}
| Rust | JavaScript |
|---|---|
seek_first(), seek_last() |
seekFirst(), seekLast() |
seek_prefix(p), seek_prefix_last(p) |
seekPrefix(p), seekPrefixLast(p) |
seek_ge(key), seek_le(key), seek_lt(key) |
seekGE, seekLE, seekLT |
next(), prev(), current() |
next(), prev(), with key() and value() |
scan_count() |
scanCount() |
Each answers Some(Entry { key, value }), or None past either end.
Pages of entries
A page reads many entries in one call. It makes one move and then steps on in that move's direction, and it writes
up to n entries back to back into the cursor's buffer. JavaScript has no page, so the same entries there are a
seek and its steps, three calls an entry. You read a page's entries in place, and nothing is copied or allocated
for them.
let mut cur = db::kv("sessions")?.cursor()?;
let mut page = cur.page_prefix("user:7:", 100)?;
loop {
let end = page.end();
for e in page {
// e.key and e.value are the entry's bytes
}
if end {
break;
}
page = cur.page(100)?;
}
| Rust | Its first move |
|---|---|
page_first(n), page_last(n) |
seekFirst(), seekLast() |
page_prefix(p, n), page_prefix_last(p, n) |
seekPrefix(p), seekPrefixLast(p) |
page_ge(key, n), page_le(key, n), page_lt(key, n) |
seekGE, seekLE, seekLT |
page(n) |
a step on from the last move, in its direction |
page_back(n) |
prev() |
A move backwards (page_last, page_prefix_last, page_le, page_lt, page_back, or a seek backwards before
page) answers its entries last first. A page under a prefix stays under it. end() is true when the range ended
in the page, so the next page is empty. A page can hold fewer than n entries without end() when the buffer ran
out of room. The next page(n) carries on from the first entry it left out. An entry too large for the buffer
grows it, and the page is read again. An n of 0 is refused with INVALID_ARGUMENT.
SQL
db::sql(text, params) runs one statement over the app's tables, as db.sql(text, params) does, through the same
engine and the same plan cache. db::prepare(text) keeps a statement for many runs. A parameter is a Param
(Null, Bool, I64, U64, F64, Text or Bytes), and From turns plain values into one. The answer is
written straight into your function's memory, one 16-byte cell a value, and the text and bytes of a row are read
there with no copy.
use substrio_wasm::db::{self, Param};
db::sql("INSERT INTO orders (item, qty) VALUES (?, ?)", &["book".into(), 2i64.into()])?;
let top = db::prepare("SELECT item, qty FROM orders WHERE qty >= ? ORDER BY qty DESC")?;
for row in top.all(&[Param::I64(2)])?.iter() {
let item = row.by_name("item").as_str();
}
| Rust | JavaScript | What it answers |
|---|---|---|
db::sql(text, ¶ms) |
db.sql(text, params) |
Rows: the rows of a query, or changes() and last_insert_id() of a statement with none (has_rows() tells which) |
db::prepare(text) |
db.prepare(text) |
a Statement, closed when it is dropped |
stmt.all(¶ms), stmt.first(¶ms) |
all(...args), first(...args) |
Rows; first holds one row at most |
rows.get(row, col), row.get(col), row.by_name(name) |
the row's members | a SqlValue, read in place |
db::last_error_message() |
the thrown error's message | the text of the last error |
A value maps as JavaScript maps it: an unsigned column is U64, a binary column is Bytes, and a number, text or
null is what the engine holds.
Transactions
db::transaction(|| { ... }) runs the closure as one SQL transaction, as db.transaction(fn) does. A closure that
returns Ok commits. A closure that returns Err rolls back and propagates the error. A transaction inside another is a
savepoint of the one around it. A collection write inside a transaction is undone with it, as in JavaScript. A KV or
graph write inside one is refused with UNSUPPORTED_OPERATION, as JavaScript's is. A transaction your function leaves
open when it returns is rolled back.
db::transaction(|| -> Result<(), db::Error> {
db::sql("UPDATE stock SET qty = qty - 1 WHERE item = ?", &["book".into()])?;
db::sql("INSERT INTO sales (item) VALUES (?)", &["book".into()])?;
Ok(())
})?;
The graph
db::graph(label) opens the app's graph label, made at its first edge, as db.graph(label) does. A node is a
u32. db::graph_labels() lists the labels the app has.
| Rust | JavaScript | Return value |
|---|---|---|
g.add_edge(src, dst) |
addEdge(src, dst).added |
true when the edge is new |
g.remove_edge(src, dst) |
removeEdge(src, dst).removed |
true when the edge was there |
g.has_edge(src, dst) |
hasEdge(src, dst).exists |
whether the edge is there |
g.neighbors(node, Direction::Out), neighbors_into(node, dir, &mut vec) |
neighbors(node, 'out' or 'in').neighbors |
the nodes the edges reach, in the graph's order |
g.degree(node) |
degree(node) |
(out, in), the counts of the node's edges each way |
g.edge_count() |
stats().edgeCount |
the label's edges |
db::graph_labels() |
db.graphLabels() |
the labels |
JavaScript returns an object for the first three operations, such as { label, src, dst, added }. Rust returns the boolean.
Refusals
A failed call returns Err(db::Error { code }). The numeric code corresponds
to an error that JavaScript throws by name:
| Code | Name | When |
|---|---|---|
0x0102 |
INVALID_ARGUMENT |
an empty collection name |
0x8001 |
TYPE_MISMATCH |
the name is held by a tree of another kind (a kv store) |
0x1000 |
STORAGE_IO |
the collection's tree could not be made |
0xC004 |
COLLECTION_NOT_FOUND |
a collection handle from another call or another lease of the app's database |
0x0106 |
INTERNAL |
a stored document whose bytes do not read as a record |
0x2000 |
OUT_OF_MEMORY |
the function's memory limit is past the address its store is mapped at, or the store is larger than its address space (SUBSTRIO_WASM_SPACE_BITS) |
0x400A |
ID_RESERVED |
insert of a document that names _id |
0xF003 |
INVALID_JSON |
a write's document or fields that are not a JSON object |
0x3005 |
TIMEOUT |
a write that waited for another call's open db.transaction past the call's deadline |
0x0103 |
INVALID_HANDLE |
a graph, cursor or statement that is closed, or that belongs to another call |
0x4001 |
KEY_TOO_LARGE |
a KV key past 65,535 bytes |
0xB006 |
SQL_FAILED |
a statement the SQL engine refuses: its text, a missing table or a constraint (the message says which) |
0xB002 |
RESULT_SET_TOO_LARGE |
an answer past what one call can hand back (a row form or SQL answer of 2^62 bytes and more) |
0xF009 |
UNSUPPORTED_OPERATION |
a KV or graph write inside db.transaction, or a transaction opened while a collection write runs on the same thread |
0xC036 |
NO_CALL_CONTEXT |
a data call made outside a request, where there is no app database |
A write rejected for its content returns the code JavaScript throws for the same document: UNIQUE,
SCHEMA_REFUSED, TOO_DEEP, RECORD_TOO_LARGE and the other document limits in the function errors table.
What a data call costs
| Step | Cost |
|---|---|
| The import | 1 load of the host table and 1 indirect call |
| The collection handle | 2 loads and 2 compares |
| The id | its base62 text decoded (at most 11 characters) |
find_by_id |
1 descent of the collection's tree and 1 walk of the record in place under its read protocol; per field one 12-byte entry (its type, and the offset and length of its bytes in the record), per row a 24-byte head with the record's address and its _id text. No stored byte is copied, except a value under a running type conversion |
| A value in the crate | 1 load of its entry's offset and 1 add to the row's record address. A converted value counts from the row's own bytes instead, which costs 1 test and 1 select |
| A nested object or array | 1 import when it is read: one entry per field or element, as for a row |
| The read's check | 1 import a read: 1 load and 1 compare a row |
count, exists |
1 load (the maintained count) and 1 test when the collection declares no expiry; exists adds 1 seek |
| A chain's step | No import. The crate appends its code and operands to the program |
| A chain's terminal | 1 import for the program and its run. all and first read rows as find_by_id does, with 1 more import for the read's check; count, exists, and the folds return a number. A page's two tokens are copied into Strings |
| Preparing a chain | 1 import to prepare it and 1 when it is dropped; later runs pass the values and terminal without decoding the steps again |
insert |
1 import: the write gate (1 load and 1 compare while no transaction is open), then JavaScript's insert of JSON text (InsertScope::ingest_json) with no JSON.stringify before it, and the id's base62 spelling (at most 11 bytes) |
set |
1 import: the fields' JSON copied once into a reused padded buffer and parsed, then JavaScript's apply (EditApply::apply_one) under one WriteBatch |
delete |
1 import: JavaScript's delete of one document (delete_live_document) |
| A KV read | 1 import: the store's handle (2 loads, 2 compares), 1 descent of its tree, and the value copied once into your buffer, checked against the record's header word (KV values are read under the same check JavaScript's are, which needs the copy). No allocation once your buffer is grown |
| A KV write | 1 import: the transaction gate (1 load, 1 test), the key's bound, then JavaScript's write (kv_put, kv_remove, kv_increment) |
| A cursor step | 1 import: the step and the entry copied once into the cursor's buffer, key and value together |
db::sql, stmt.all, stmt.first |
1 import: the parameters read in place (16 bytes each), the engine's run, and the answer written straight into your memory; an answer larger than your buffer is handed in once, after the engine lets go of its lock |
db::transaction |
2 imports: its opening (the engine's writer gate and a savepoint) and its end |
| A graph call | 1 import: the graph's handle (1 load, 1 compare), then the graph's own call; a write takes the tenant's sync manager, kept per instance, and its tree name, made once a label |
| The store's mapping | once per instance: 1 open and 1 map of the store's file (one per stripe), the file mapped whole so the store grows into it |
The same reads JavaScript makes come from the same code: src/substrio/runtime/ owns them, and each language only
turns its arguments into typed ones and its answers into its own values.
The ABI the crate implements
A module is wasm64 (memory64): it imports from substrio and exports three functions, and every address and
length is a u64 in the module's address space (a handle or a flag is a u32).
| Import | Signature | |
|---|---|---|
log |
(at, len) |
one log line |
api |
(method, len, path, len, body, len, auth, len) -> len |
the answer through substrio_alloc; u64::MAX without a call context |
env |
() -> len |
the env as a pair blob through substrio_alloc |
current_user |
() -> len |
JSON through substrio_alloc |
check_permission |
(at, len) -> 0 or 1 |
|
fail |
(at, len) |
the message of the failure about to trap |
collection |
(name, len) -> handle |
the database (above); declared once with the rest of the data surface in src/substrio/runtime/surface.def |
find_by_id |
(handle, id, len, out, cap) -> len |
0 for none; the row's entries (the ROWS FORM of src/substrio/wasm/wasm_abi.h) in out when len <= cap |
nested |
(handle, type, at, len, out, cap) -> len |
a nested object's or array's entries (the NESTED FORM) |
rows_valid |
() -> 0, or 1 + a row |
the read's check: the first row a writer changed |
schema |
(handle, out, cap) -> len |
the collection's field names, which an entry names by its slot |
count |
(handle) -> u64 |
|
exists |
(handle) -> 0 or 1 |
|
chain_run |
(collection, program, len, out, cap) -> len |
runs an encoded chain and terminal |
chain_prepare |
(collection, program, len) -> handle |
decodes and keeps a chain for later runs |
chain_exec |
(handle, tail, len, out, cap) -> len |
runs a prepared chain with its arguments and terminal |
chain_drop |
(handle) -> 0 |
releases a prepared chain |
insert |
(handle, json, len, out) -> len |
the new _id's base62 text at out (room for 11 bytes), its length answered |
set |
(handle, id, len, json, len) -> 0 or 1 |
1 when the document was written, 0 when no document has the id |
remove |
(handle, id, len) -> 0 or 1 |
delete(id): 1 when the document existed and is removed |
An invalid step is reported when the terminal runs the encoded chain.
A row carries its record's address once, and each value carries the 32-bit offset and length of its bytes from it
in the record-relative representation introduced in ABI 3. A stored value always lies inside its record, and a record is at most 4 GiB less one byte, so the offset is
exact. A converted value has the kValueOwn flag, and its offset counts from the row's own head instead. A nested
object's or array's entries count from the nested value's own address, which is the at that nested was given.
nested refuses a value longer than a record with INVALID_ARGUMENT.
A data import's answer at or above 0xFFFF0000 (a u64 answer: 0xFFFFFFFFFFFF0000) is a refusal, its low 16
bits the error code.
| Export | Signature | |
|---|---|---|
substrio_in |
(len) -> at |
a buffer the platform writes the request into |
substrio_alloc |
(len) -> at |
room for an import's answer |
substrio_fetch |
(at, len) -> record |
runs the call; the address of its answer record |
The request head, the answer record and the pair blob (u32 key length, u32 value length, key, value) are laid
out in src/substrio/wasm/wasm_abi.h.
Entries
A Rust function answers every entry a JavaScript function does. Each entry is a handler of the shape of fetch,
fn(&Request, &Env, &Ctx) -> Response, and you name the ones you write in export!:
substrio_wasm::export!(fetch: fetch, scheduled: scheduled, queue: queue, trigger: trigger, call: call);
export!(handler) is the route-only form and stays as it was. For every entry other than a route, req.text() is
the event's JSON, exactly the bytes a JavaScript handler is given as its event, and req.method() is POST.
| Entry | Rust | JavaScript | What the request holds | What the answer does |
|---|---|---|---|---|
| A route | fetch |
fetch(request, env, ctx), else the default function |
the HTTP request | the HTTP response |
| A cron run | scheduled, else call |
scheduled(event, env, ctx), else the default function |
the cron event: trigger, cron_id, function, timestamp, and manual for a run made by hand |
a run that answers a status under 400 has succeeded, and its body is the run's result, as the Response a JavaScript handler returns is. A trap, a limit or a status of 400 or more is a failed run, which is retried with backoff, as a JavaScript throw is |
| A queue delivery | queue |
queue(batch, env, ctx) |
the batch: trigger, queue, timestamp and messages, each with seq, id, timestamp, attempts and body. Batch::of(req) reads it in place |
an Outcome (below): one typed verdict for each message, in the batch's order. A message with no verdict is retried, so an answer with no verdicts, or a failed run, retries the whole batch. An answer with a status of 400 or more is a failed run: its verdicts are not read |
| A document trigger | trigger, else call |
the default function, given {"trigger":{...}} |
{"trigger":{"type","collection","documentId","timestamp","data"}} |
a run that answers a status under 400 has succeeded. A trap, a limit or a status of 400 or more is a failed run, which the trigger retries |
| A direct call | call, else fetch |
the default function | /fn/<name>: the request's body. A call from another function: the arguments as JSON |
the body of the answer is the call's result, and its status is the call's status (a call is never retried) |
A cron run, a trigger run and a queue delivery fail, and are retried by that entry's own policy, when the handler
traps, hits a limit or answers a status of 400 or more, and a JavaScript handler's does the same when it throws or
returns a Response of 400 or more. The answer's body is the run's error. A direct call keeps its status as its
answer in both runtimes. Response::with_status(code) changes the status of a response you already built, such as
an Outcome's.
A queue has no fallback, because only a consumer written for a batch can settle one. The crate has no JSON reader,
so a cron, trigger or call handler reads its event as text. The fixture tests/wasm/fixtures/entries.rs shows each
entry.
A queue consumer
A consumer reads its batch with Batch::of(req) and answers an Outcome. Neither costs an allocation beyond the
outcome's own, and the guest writes no JSON.
fn queue(req: &Request, _env: &Env, _ctx: &Ctx) -> Response {
let batch = Batch::of(req);
let mut outcome = Outcome::with_capacity(batch.len());
for message in batch.messages() {
if message.attempts < 3 && !process(message.body) {
outcome.retry_after(1000);
} else {
outcome.ack();
}
}
outcome.into_response()
}
| Rust | JavaScript |
|---|---|
batch.queue() |
batch.queue |
batch.messages(), each a Message with seq, id, timestamp, attempts and body (the JSON text it was sent as) |
batch.messages, each with id, timestamp, attempts and body |
outcome.ack() |
message.ack() |
outcome.retry() |
message.retry() |
outcome.retry_after(ms) |
message.retry({ delay: ms }) |
Outcome::ack_all(n), Outcome::retry_all(n) |
batch.ackAll(), batch.retryAll() |
Each call to ack, retry or retry_after is the verdict of the next message, in the order messages() lists them.
The answer is 8 bytes a message (one verdict byte and a delay in milliseconds, QueueVerdict in
src/substrio/wasm/wasm_abi.h), and the queue reads the bytes where they lie: no JSON is parsed and nothing is
allocated. A message the outcome has no word for is retried, as a JavaScript message its consumer did not settle is.
A message retried more times than the queue allows is dead-lettered as it is for JavaScript.
An entry the function has no handler for ends as a JavaScript call that throws. The run fails with the message
substrio-wasm: this function exports no scheduled or call entry (export!(scheduled: handler)), and its kind's
words name what to export. A cron run made by hand returns it as {"outcome":"error","error":...}.
Calls between functions
substrio_wasm::services::call(name, args_json, &mut out) is substrio.functions.<name>(args), and it reaches a
Rust function and a JavaScript one alike. A JavaScript function calls a Rust one with substrio.functions.<name>(arg)
as it calls a JavaScript one. The arguments are at most one value, sent as JSON, and the answer is the JSON the
callee answered, or its text when it is not JSON. The callee runs nested on the caller's thread, for the caller's
tenant, as the caller's user, and under the caller's remaining CPU. A callee that runs out of the caller's limit
ends the caller too. The depth is limited by jsc.limit_call_depth, and a call inside db.transaction is refused.
A name that is not a function of the app is refused with FUNCTION_NOT_FOUND.
Calling a Rust function from JavaScript gives the callee the tenant's CPU and memory limits afresh, because a JavaScript call's own budget belongs to its engine. The caller's wall deadline still ends the call. Calling a JavaScript function from Rust gives the callee a fresh wall timeout of the tenant's, and the caller's deadline is checked when the callee returns.
What an entry costs
| Entry | Added to a route's cost |
|---|---|
| Any entry, in the module | 1 load of the head's kind, 1 bounds compare and 1 indexed load of the function's table, which holds each kind's handler already resolved when the module is compiled, and 1 indirect call where a route calls its handler directly. export!(handler) has no table and adds nothing |
| A cron run, a queue delivery, a trigger | the event's JSON copied once into the module as the body, and the head written once from a thread-local buffer. No allocation, no extra thread hop and no second queue: the run takes the path a JavaScript run of the same entry takes |
/fn/<name> |
the route match's flags byte, already read, marks the call and the function's slot and generation are taken from the match, so no second lookup |
| A direct call, Rust to Rust | the nesting of a Rust call (an instance of the callee, no thread hop); 16 calls can run at once on a thread |
| A JavaScript function's own runs | An app that never stored a Rust function pays 1 load of one pointer on the cache line the run already reads, and 1 branch. An app that stored one pays 1 thread-local pin, 1 acquire load of an immutable table of its Rust functions and 1 probe in it for each cron run, queue delivery, trigger and /fn call. It takes no lock. A deploy, a toggle or a delete of a Rust function copies the table and swaps it, so the next run sees the function's new runtime. The table is never removed, so an app that drops its last Rust function keeps an empty one and still pays the probe |
In a tenant's cell
A server started with --cells runs a tenant's code in the tenant's own cell, a process the supervisor owns. A Rust
function deploys and runs there as it does anywhere else. The server compiles it when it is deployed. Its first call
in a cell sends the cell the compiled module and the tenant's CPU, memory and wall limits, and the cell opens the
module once. A call then costs the cell's ring and nothing else beyond a Rust call on the server: the request head the
server wrote is copied into the module once, and the answer is sent from the module's memory. A change to a limit
sends the function to the cell again, and the next call runs under it. The CPU and wall limits end a call in a cell
with the same status, code and counter as anywhere else.
A cell holds the tenant's database as a read-only mapping and has no System over it yet. A function in a cell
computes with no data calls until the cell's read-only System lands, in Rust and in JavaScript alike. It cannot call
the platform. Its first call of db, services, the ledger or any other import of the platform answers
WASM_CELL_IMPORT_UNSUPPORTED in the form every refusal of the data surface has (substrio_wasm::db::Error, code
0xC59D), and the function decides what to answer. The code 0xC592, WASM_CELLS_UNSUPPORTED, is retired: no server
answers it, and it stays named because published clients map it. A function that computes from its request runs in a cell without
change. Until the cell supports platform calls:
env.getandenv.pairsare empty.substrio_wasm::apianswers that the call has no context.auth::check_permissionends the call with the message that it does not run in a cell yet.auth::current_userand the request's ownauth_jsonwork, because the caller's JSON is in the request.- A cron run, a queue delivery, a document trigger and a call by another function run on the server, not in a cell.
| Code | Status | When |
|---|---|---|
WASM_CELL_IMPORT_UNSUPPORTED (0xC59D) |
409 | a Rust function in a cell called a data, services, or ledger operation that the cell does not support yet |
Limits
Every limit is the JavaScript function's, with the same status, code and counter.
| Limit | Set by | Status and code | Counter (tenant.usage.self calls) |
|---|---|---|---|
| CPU | cf_cpu_limit_ms |
408 CF_CPU_TIMEOUT |
ended.cpu_limit |
| Memory | cf_memory_limit_mb: the module's memory can grow no further |
413 CF_MEMORY_EXCEEDED |
ended.heap_limit |
| Wall time | cf_timeout_s |
504 CF_WALL_TIMEOUT |
ended.wall_limit |
| Response size | 64 MiB | 413 REQUEST_TOO_LARGE |
ended.too_large |
| Request size | 1 MiB in this release (a larger body is streamed to JavaScript; streaming reaches Wasm in a later release) | 413 REQUEST_TOO_LARGE, before the module runs |
ended.too_large |
| The app's memory budget | cf_tenant_memory_limit_mb |
503 CF_TENANT_MEMORY |
refused.tenant_memory |
| The server's memory | its hard watermark | 503 CF_SERVER_MEMORY |
|
| Calls at once | cf_concurrent_limit |
429 RATE_LIMIT_EXCEEDED |
refused.concurrent |
| A suspended app | 403 TENANT_SUSPENDED |
refused.suspended |
A trap (a panic, unreachable, a memory access out of bounds, a stack overflow) answers 500
CF_EXECUTION_FAILED with the function error a JavaScript throw gives. A panic's message is the error's
message. Every call adds to invocations, wall_ns, its status class and its latency bucket; a call under a CPU
limit also adds its CPU to cpu_us, as a JavaScript call does (an app with no CPU limit is not metered). The CPU
limit is checked every 10 ms: a call ends within 10 ms of CPU past it.
The module keeps its memory between calls, as a JavaScript isolate keeps its globals, and is made again after a
trap or a limit ended a call. A function holds one instance for each call it runs at once, on whichever thread runs
it, so an instance can serve any thread. There is no limit on how many functions a server holds, and the operating
system pages out what is not in use. An instance that has had no calls for 10 minutes is freed, and the next call
makes a new one. The jsc.idle_drop_ms platform setting changes that time, and 0 keeps every instance.
A function is compiled once, when it is deployed, and its record stores the address of the compiled module. After the deploy, nothing reads or hashes its source to run it. A release with a new compiler has no compiled file yet, so each function compiles once, at its first call on that release.
Refusals at deploy and edit
Nothing is stored when a deploy is refused.
| Code | Status | When |
|---|---|---|
RUST_COMPILE_FAILED |
422 | the source does not compile, or does not link (no substrio_wasm::export!(fetch)). detail is the compiler's own diagnostics in full, each naming src/lib.rs and the line and column. A compile stopped at one of its limits names that limit (its time, its CPU time, its memory, or diagnostics past 16 MiB) and keeps the diagnostics it wrote |
WASM_MODULE_INVALID |
400 | the compiled module breaks the platform ABI by importing from a module other than substrio, or importing something substrio does not provide. The message identifies the import |
WASM_COMPILE_FAILED |
422 | it compiled, but the native compile did not finish within the deploy's time and memory limits |
RUST_TOOLCHAIN_UNAVAILABLE |
503 | the server cannot compile Rust: its release has no rust/ folder (a dev build is given one with -DSUBSTRIO_RUST=<a rust.sh bundle>), or the tools jail could not run it (the detail says why) |
For example, a type error on line 4:
{ "error": "The Rust source did not compile; the detail is the compiler's own diagnostics, ...",
"code": "RUST_COMPILE_FAILED", "status": 422, "retryable": false, "next_action": "correct_request",
"detail": "error[E0308]: mismatched types\n --> src/lib.rs:4:18\n |\n4 | let n: u32 = req.path();\n | --- ^^^^^^^^^^ expected `u32`, found `&str`\n ..." }
detail holds the compiler's diagnostics as it printed them. Fix the line and deploy again.
What a call costs
| Step | Cost |
|---|---|
| The route's kind | 1 test of the route's flags byte, already read by the match |
| The request into the module | each piece (method, url, headers, params, auth: hundreds of bytes) and the body copied once, straight into the module's memory; a call that runs on another thread than its request's (the worker pool) copies the head once more, through 1 buffer of its exact size |
| The request in the module | 0 parsing: every member is an offset |
| The CPU and wall limit | 1 load, 1 test and 1 branch at every function entry and loop header |
| Reaching the instance | the thread's last function: 1 key compare and 2 loads. Another function: 2 loads, 1 exchange, 1 compare-and-swap, and no system call, however many functions the server holds |
| A load or store of memory | 1 AND: the address is masked into the instance's address space (2^41 bytes), whose unmapped pages trap; no bounds check |
| Metering the CPU | none without a CPU limit; under one, 2 reads of the thread's CPU, as a JavaScript call: each a read of the CPU's time-stamp counter, with no system call, unless the thread was switched out since its last read (then 1 read of the kernel's clock) |
| The answer | sent from the module's memory: the body copied once into the connection's buffer, or not at all when large |
| An import's answer | 1 copy into the module (substrio_alloc) |
Not in this release
pages() is not available; loop on page with its next token. Live rows, outbound fetch, and timers are not
available. See writing records from Rust, platform services from Rust, and
ledger from Rust for the supported data and service calls and their limits. A Wasm function can
also call registry operations through substrio_wasm::api.
A Wasm function answers every entry (see Entries), but a Rust function and a JavaScript function share one name
in an app: a queue is declared by a version, and a version holds JavaScript functions only, so a Rust function
consumes a queue after a version names the queue and /v1/deploy replaces its consumer with the Rust source.
Its stored source is its Rust, the size the tenant's
functions_code_bytes_limit counts. More than one source file, and crates beyond substrio_wasm, come in a later
release.
See also Rust functions, the entry page: when to pick Rust, every call exposed to it, deploy and testing.