Wasm functions (Rust)

A function can be written in Rust and deployed from source.

Docs menu

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):

  1. 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.
  2. substrio_wasmc links 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, &params) 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(&params), stmt.first(&params) 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.get and env.pairs are empty.
  • substrio_wasm::api answers that the call has no context.
  • auth::check_permission ends the call with the message that it does not run in a cell yet.
  • auth::current_user and the request's own auth_json work, 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.