WASM Procedures (User-Defined Functions)
patinaDB lets you register your own read-only procedures compiled to
WebAssembly — callable from Cypher as CALL wasm.<name>(args), just like a
built-in procedure. This is patinaDB’s user-defined-function story: instead of
an ad-hoc scripting language, a UDF is a compiled .wasm module, which means
it can be written in any language that compiles to WebAssembly (Rust, C/C++,
AssemblyScript, Zig, …) and runs deterministically and sandboxed — the
property that makes it safe to replicate to every node in a cluster.
CREATE FUNCTION fib (1) LANGUAGE wasm FROM 'file:///opt/patinadb/fib.wasm' EXPORT 'fib';
CALL wasm.fib(10) YIELD result RETURN result;
-- result: 55
Why WASM
Three engines were evaluated (Rhai, LuaJIT, WASM/wasmtime) for embeddable user-defined logic. WASM won on both axes that matter for a replicated database:
- Speed. Near-native on compute, and the fastest scripted call of the three.
- Determinism by construction. A WASM guest module can be sandboxed so it imports nothing from its host — no clock, no RNG, no filesystem, no network. With host imports denied, the same module given the same input produces the same output on every run and every node, every time. That is the property a Raft-replicated write path requires, and it is why patinaDB doesn’t offer a general scripting language as its UDF surface.
A WASM function is currently read-only: pure compute over its scalar arguments, plus (as of increment 2) bounded point-reads against the graph. It cannot write, and cannot reach the filesystem, network, clock, or any randomness source.
Registering a function
CREATE FUNCTION [IF NOT EXISTS] <name> [(<arity>)] LANGUAGE wasm
FROM '<file-url>' EXPORT '<entry-export-name>'
DROP FUNCTION [IF EXISTS] <name>
SHOW FUNCTIONS
<name>is a bare identifier (or a backtick-quoted one) and becomes callable asCALL wasm.<name>(...).<arity>is optional and documents the expected argument count; it’s not currently enforced beyond what the guest’s ABI decoding does.LANGUAGE wasmis required — it’s the only language patinaDB currently compiles, so this is future-proofing the grammar rather than a real choice today.FROM '<file-url>'is afile://URL. The module’s bytes are read once, atCREATE FUNCTIONtime, compiled, validated (see Sandbox & determinism below), and then stored — the file itself is never consulted again. This matters for two reasons: a reopened database re-activates the function with no dependency on the original file still existing, and on a cluster the compiled module’s bytes (not the path) are what gets replicated — a follower obviously can’t read a file that only exists on the leader’s disk.EXPORT '<entry-export-name>'names the guest function to call — see the ABI section below for what that function must look like.- A module that fails to compile, imports anything not on the allowed list
(see below), or doesn’t export the required ABI surface, is rejected at
CREATE FUNCTIONtime with a clear error, and nothing is persisted.
DROP FUNCTION removes the definition and makes wasm.<name> uncallable.
SHOW FUNCTIONS lists what’s registered.
A worked example
Here is a minimal, complete fib guest written directly in WebAssembly text
format (WAT) — small enough to read end to end, and a realistic shape for
whatever toolchain compiles your language of choice to .wasm. Save it as
fib.wat, assemble it to fib.wasm with wat2wasm (from
WABT), and register it:
(module
(memory (export "memory") 1)
(global $bump (mut i32) (i32.const 1024))
;; A bump allocator — the host uses this to hand the guest its own args.
(func $alloc (export "alloc") (param $n i32) (result i32)
(local $p i32)
(local.set $p (global.get $bump))
(global.set $bump (i32.add (global.get $bump) (local.get $n)))
(local.get $p))
;; fib(n): Int -> Int
(func (export "fib") (param $in i32) (param $len i32) (result i64)
(local $n i64) (local $a i64) (local $b i64) (local $t i64) (local $i i64)
(local $out i32)
(local.set $n (i64.load align=1 (i32.add (local.get $in) (i32.const 5))))
(local.set $a (i64.const 0))
(local.set $b (i64.const 1))
(local.set $i (i64.const 0))
(block $done
(loop $loop
(br_if $done (i64.ge_s (local.get $i) (local.get $n)))
(local.set $t (i64.add (local.get $a) (local.get $b)))
(local.set $a (local.get $b))
(local.set $b (local.get $t))
(local.set $i (i64.add (local.get $i) (i64.const 1)))
(br $loop)))
(local.set $out (call $alloc (i32.const 9)))
(i32.store8 (local.get $out) (i32.const 1))
(i64.store align=1 (i32.add (local.get $out) (i32.const 1)) (local.get $a))
(i64.or (i64.shl (i64.extend_i32_u (local.get $out)) (i64.const 32)) (i64.const 9))))
CREATE FUNCTION fib (1) LANGUAGE wasm FROM 'file:///opt/patinadb/fib.wasm' EXPORT 'fib';
CALL wasm.fib(10) YIELD result RETURN result;
-- result: 55
CALL wasm.fib(20) YIELD result RETURN result;
-- result: 6765
If you’re writing the guest in Rust instead of hand-rolled WAT, the same ABI
applies: export memory, alloc, and your entry function with the exact
signatures in The ABI below; the wasm32-unknown-unknown
target with #[no_std] (or a thin marshalling shim over std) compiles
cleanly to a module with no imports.
Reading the graph from a function (increment 2)
A WASM function isn’t limited to its scalar arguments — it can also do
bounded point-reads against the graph it’s called from, over a
consistent snapshot of the database’s current committed state (HEAD). This
lets a function compute over a node’s properties, labels, or neighbours
without patinaDB having to expose general graph traversal to the sandbox.
A guest opts in by importing exactly these three host functions from the
module "patinadb" (any other import — a WASI clock, env anything — is
rejected at CREATE FUNCTION time):
| Host function | Signature | Returns |
|---|---|---|
node_get_property | (id_ptr, prop_ptr, prop_len) -> i64 | The property’s value (Null if absent). |
node_labels | (id_ptr) -> i64 | A List of the node’s labels as strings. |
node_neighbors | (id_ptr, dir, rel_ptr, rel_len) -> i64 | A List of neighbouring node UUIDs. dir: 0=out, 1=in, 2=both. rel_len=0 means any relationship type. |
Call one of these by passing a node argument through to your entry function —
CALL wasm.readScore(n) evaluates n to the node’s UUID, which the entry
function then hands to node_get_property. See
crates/patinadb-wasm/examples/guest.wat in the repository for a complete
reference guest that imports all three.
Bounded, so a function can’t read the whole graph unboundedly: each host
call counts against a per-invocation budget
(PATINADB_WASM_MAX_GRAPH_READS, default 100,000 calls) — once exceeded the
guest traps with a clean error, never a hang. node_neighbors is additionally
capped at a maximum number of returned neighbour ids per call. A malformed
request (an out-of-bounds pointer, an unknown direction, or calling these
outside of a query context) is also a clean trap, never a panic or a crash.
Still read-only: none of these host functions can create, update, or delete anything.
Determinism & the sandbox
Everything above rests on one guarantee: the same module given the same input produces the same output, everywhere, every time. patinaDB enforces this at the engine level, not by convention:
- No host imports beyond the documented allow-list. A guest module is instantiated with an empty import list by default; the only imports ever accepted are the three graph-read functions above, and only when a graph context is available. Anything else — a WASI clock, a random-number source, a filesystem call, an environment-variable read — is rejected at registration, before the module is ever run.
- Fuel is the deterministic resource bound. Every invocation gets a fixed instruction budget (100,000,000 units by default). The same module and input consume the same fuel and trap at the same point on every node — this is what makes an accidental infinite loop safe to replicate rather than a cluster-wedging hang.
- Wall-clock (
epoch) and memory are safety nets, not the primary bound. A 5-second wall-clock deadline and a 16 MiB linear-memory cap protect a single node against a runaway guest, but neither is used to gate a replicated write, because wall-clock timing is inherently nondeterministic across machines. - Strict IEEE-754 floating point, with no relaxed-SIMD and NaN canonicalization enabled, so floating-point results are bit-identical across platforms too.
A guest that traps (fuel exhausted, memory cap hit, deadline exceeded, out-of-bounds access, or an explicit guest panic) surfaces as a clean query error — never a process crash or a hang.
Because of this sandboxing, patinadb-wasm is an isolated, opt-in crate:
core patinadb has no dependency on it and pulls in no WebAssembly runtime by
default. A server or embedding application opts in explicitly (see below); a
plain build of the core library is unaffected either way.
Running on the server (replication & RBAC)
CREATE/DROP FUNCTION are DDL, and on a clustered server they behave like
any other schema change:
- Requires the global
adminrole.CREATE FUNCTIONreads a file off the leader’s local disk, so it’s authorized likeLOAD CSVand the CSV export procedures — by effect, not by role level. A per-database Writer is refused; only a global Admin can register or drop a function. The file path is confined by the same--allow-csv-dirsandboxLOAD CSVuses (see Cypher-driven file I/O). - Replicated by value, not by path. The leader reads, validates, and compiles the module exactly once, then proposes the resulting definition — module bytes included — as a single Raft log entry. Every node applies it identically: persists the definition and, if it has WASM support installed (see below), activates it. A node without WASM support still stores the definition (so it stays consistent and forwards writes correctly) but can’t execute the function locally.
- Snapshot-carried. A node that bootstraps purely from a streamed Raft
snapshot — with the original
CREATE FUNCTIONlog entry long since purged — still ends up with every registered function, because the snapshot itself carries the compiled module bytes. - Module-size cap. A registered module is capped at
PATINADB_MAX_WASM_MODULE_BYTES(default 8 MiB) so a large module can’t blow past the Raft peer-RPC body-size limit.CREATE FUNCTIONfails loudly, before anything is proposed, if the module is over the cap.
A server enables this feature with one line at startup
(patinadb_wasm::install(), already wired into the shipped patinadb-raft
binary) plus a build-time dependency on the patinadb-wasm crate — core
patinadb remains WASM-free either way.
The ABI (for guest authors)
A guest module must export:
memory— its linear memory.alloc(len: i32) -> i32— allocatelenbytes and return the pointer. Any allocation strategy works (the reference guest above uses a trivial bump allocator); the host calls this to place argument bytes into the guest’s memory before invoking the entry function, and the guest calls it itself to place its result.- The entry function named in
EXPORT '<name>', with signature(in_ptr: i32, in_len: i32) -> i64. The return value packs a pointer and a length into one 64-bit integer:(out_ptr as u64) << 32 | out_len as u64.
Wire format — a small, frozen, tag-prefixed binary encoding, deliberately not patinaDB’s internal storage format, so it stays a stable, language-agnostic contract for any guest toolchain:
-
The argument blob at
in_ptrisu32 count(little-endian) followed bycounttag-prefixed values. -
The result blob the guest allocates and returns is exactly one tag-prefixed value.
-
Each tagged value is one byte for the tag, then the payload:
Tag Type Payload 0Null (none) 1Integer i64, little-endian2Float f64bits, little-endian3Bool one byte, 0/14String u32length + UTF-8 bytes5List u32count + that many tagged values6Uuid 16 raw bytes (a node/relationship handle) 7Map u32count + that many(u32 key-len + key bytes + tagged value)entries8Node a Uuid + a labels-List + a properties-Map 9Relationship a start Uuid + an end Uuid + a type-String + a properties-Map
A Cypher value outside this set — a temporal value, a spatial Point/Polygon,
or a multi-hop Path — is rejected loudly as an unsupported argument/result
type rather than silently coerced.
Limitations
- Read-only. A WASM procedure cannot create, update, or delete graph data.
- Not a scalar UDF (yet). A WASM function is a procedure, called with
CALL wasm.<name>(...) YIELD result— it does not (yet) compose into an arbitrary expression position likeRETURN f(n.x)or aWHEREclause. - No filesystem, network, clock, or randomness inside the guest, ever — this is the determinism guarantee, not a configurable option.
- The scalar/entity ABI subset above is everything. Temporal values,
spatial
Point/Polygon, and multi-hop paths are not (yet) marshalled across the boundary. - Graph reads are single-vertex point-reads and bounded neighbour lists — there’s no general traversal or query-execution host call from inside a guest.
CREATE EDGE SORTED INDEX-style module toolchains aren’t required or assumed — you bring your own WASM compiler; patinaDB only defines the ABI a compiled module must satisfy.
The full design write-up — including the alternatives considered and the
increment-by-increment implementation history — lives in the repository’s
docs/rfcs/0017-wasm-embedded-procedures.md.