sequenceDiagram
participant JS as TypeScript SDK
participant Pack as packToWasm (msgpack)
participant WB as wasm_bindgen
participant Rust as panproto-wasm
participant Unpack as unpackFromWasm
JS->>Pack: structured JS value
Pack->>WB: Uint8Array
WB->>Rust: &[u8] (zero-copy view)
Rust->>Rust: rmp_serde::from_slice → typed Rust struct
Rust->>Rust: perform operation
Rust->>Rust: rmp_serde::to_vec → Vec<u8>
Rust->>WB: Vec<u8>
WB->>JS: Uint8Array
JS->>Unpack: Uint8Array
Unpack->>JS: structured JS value
13 panproto-wasm: The WASM Boundary
The panproto-wasm crate is the glue layer between the Rust core and the TypeScript SDK. It exposes #[wasm_bindgen] entry points for schema construction, migration, analysis, lens operations, GAT engine access, and version control. Every structured value crossing the boundary is serialized as MessagePack bytes, never as a JS object graph.
13.1 Design rationale
Two design decisions shape everything:
Handle-based API. Complex Rust objects (protocols, schemas, compiled migrations) never cross the boundary. Instead, they live in a Rust-side slab allocator, and JS holds opaque
u32handles. This avoids the overhead and fragility ofserde-wasm-bindgen, which must recursively convert Rust structs into JS objects on every call.MessagePack over serde-wasm-bindgen. Structured arguments (builder operations, migration mappings, records) are encoded as MessagePack byte slices with
rmp_serde. MessagePack is faster than JSON for encode/decode, produces smaller payloads, and (critically) is deterministic: the same Rust struct always encodes to the same bytes, which simplifies testing and caching.
The handle + MessagePack design means the WASM boundary has zero JS-heap allocations for data that stays on the Rust side. The only JS objects created are the Uint8Array wrappers around byte slices.
13.2 The resource enum and slab allocator
All WASM-side resources are stored in a thread-local Vec<Option<Resource>>. Each resource is one of ten variants:
/// A resource stored in the slab.
///
/// Schemas are stored behind `Arc` so that `MigrationWithSchemas`
/// can share ownership without deep-cloning on every `lift_record`
/// or `get_record` call.
pub enum Resource {
/// A protocol specification.
Protocol(Protocol),
/// A built schema.
Schema(Arc<Schema>),
/// A compiled migration ready for per-record application.
Migration(CompiledMigration),
/// A compiled migration bundled with its source and target schemas,
/// needed for lens put operations and accurate schema reconstruction.
MigrationWithSchemas {
/// The compiled migration.
compiled: CompiledMigration,
/// The source schema (pre-migration).
src_schema: Arc<Schema>,
/// The target schema (post-migration).
tgt_schema: Arc<Schema>,
},
/// An I/O protocol registry with all 77 protocol codecs.
IoRegistry(Box<ProtocolRegistry>),
/// A GAT theory.
Theory(Box<Theory>),
/// A VCS in-memory repository.
VcsRepo(Box<MemStore>),
/// A protolens chain (reusable, schema-independent).
ProtolensChain(Box<ProtolensChain>),
/// A symmetric lens.
SymmetricLensHandle(Box<SymmetricLens>),
/// A data set (instances bound to a schema).
DataSet(Box<DataSetObject>),
}
thread_local! {
static SLAB: RefCell<Vec<Option<Resource>>> = const { RefCell::new(Vec::new()) };
}The Resource enum captures core resource types (protocol, schema, compiled migration, I/O registry, theory, VCS repository, protolens chain, symmetric lens, data set) plus MigrationWithSchemas, which bundles a compiled migration with its source and target schemas (needed for lens get/put operations).
The alloc function scans for a freed slot before appending, so handle values are reused. Handles are u32 indices, supporting up to 4 billion simultaneous resources.
13.3 The with_resource pattern
Accessing a resource requires borrowing the thread-local slab. The with_resource function encapsulates this: it borrows the slab, looks up the handle, and passes a reference to a callback. The borrow is released when the callback returns, preventing references from escaping:
/// Access a resource by handle, returning an error if the handle is
/// invalid or the slot is empty.
///
/// The callback `f` receives a reference to the resource. The borrow
/// is released when the callback returns, so the reference must not
/// escape.
pub fn with_resource<T>(
handle: u32,
f: impl FnOnce(&Resource) -> Result<T, WasmError>,
) -> Result<T, JsError> {
SLAB.with_borrow(|slab| {
let idx = handle as usize;
let resource = slab
.get(idx)
.and_then(Option::as_ref)
.ok_or(WasmError::InvalidHandle { handle })?;
f(resource).map_err(Into::into)
})
}This prevents a common bug: holding a reference to a slab entry while mutating the slab (which would panic due to RefCell rules). The callback-based API makes this structurally impossible.
A with_two_resources variant exists for operations that need two handles simultaneously (e.g., compile_migration needs both source and target schemas). It takes the borrow once and looks up both indices.
Returning a &Resource would require the caller to hold the RefCell borrow for the reference’s lifetime. Any nested call that also borrows the slab would panic. The callback pattern scopes each borrow precisely.
13.4 The buildop tagged Enum
Schema construction operations are sent from JS as a MessagePack-encoded Vec<BuildOp>. The BuildOp enum uses serde’s internally-tagged representation, so each operation is a flat object with an op discriminant:
/// A serializable builder operation for constructing schemas.
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "op")]
enum BuildOp {
/// Add a vertex.
#[serde(rename = "vertex")]
Vertex {
/// Vertex identifier.
id: String,
/// Vertex kind.
kind: String,
/// Optional NSID.
nsid: Option<String>,
},
/// Add a binary edge.
#[serde(rename = "edge")]
Edge {
/// Source vertex ID.
src: String,
/// Target vertex ID.
tgt: String,
/// Edge kind.
kind: String,
/// Optional edge label.
name: Option<String>,
},
/// Add a constraint.
#[serde(rename = "constraint")]
Constraint {
/// Vertex ID.
vertex: String,
/// Constraint sort.
sort: String,
/// Constraint value.
value: String,
},
/// Add a hyper-edge connecting multiple vertices via labeled positions.
#[serde(rename = "hyper_edge")]
HyperEdge {
/// Hyper-edge identifier.
id: String,
/// Hyper-edge kind.
kind: String,
/// Maps label names to vertex IDs.
signature: HashMap<String, String>,
/// The label that identifies the parent vertex.
parent: String,
},
/// Declare required edges for a vertex.
#[serde(rename = "required")]
Required {
/// The vertex that owns the requirement.
vertex: String,
/// The edges that are required.
edges: Vec<panproto_core::schema::Edge>,
},
}The five variants (Vertex, Edge, Constraint, HyperEdge, Required) mirror SchemaBuilder methods. The TypeScript SDK constructs these as plain objects and encodes them with @msgpack/msgpack.
13.5 Entry points
13.5.1 define_protocol
Registers a protocol specification and returns a handle:
/// Register a protocol specification and return a handle.
///
/// The `spec` bytes are MessagePack-encoded `Protocol` data.
///
/// # Errors
///
/// Returns `JsError` if deserialization fails.
#[wasm_bindgen]
pub fn define_protocol(spec: &[u8]) -> Result<u32, JsError> {
let protocol: panproto_core::schema::Protocol =
rmp_serde::from_slice(spec).map_err(|e| WasmError::DeserializationFailed {
reason: e.to_string(),
})?;
Ok(slab::alloc(Resource::Protocol(protocol)))
}The pattern is representative: deserialize MessagePack input with rmp_serde::from_slice, perform the operation, allocate the result in the slab, and return the handle.
13.5.2 build_schema
The most complex entry point. It takes a protocol handle and a Vec<BuildOp>, replays the operations through SchemaBuilder, and returns a schema handle:
/// Build a schema from a protocol handle and `MessagePack`-encoded
/// builder operations.
///
/// The `ops` bytes are a `MessagePack`-encoded `Vec<BuildOp>`.
///
/// # Errors
///
/// Returns `JsError` if the protocol handle is invalid, ops cannot
/// be deserialized, or schema building fails.
#[wasm_bindgen]
pub fn build_schema(proto: u32, ops: &[u8]) -> Result<u32, JsError> {
let protocol = slab::with_resource(proto, |r| Ok(slab::as_protocol(r)?.clone()))?;
let operations: Vec<BuildOp> =
rmp_serde::from_slice(ops).map_err(|e| WasmError::DeserializationFailed {
reason: e.to_string(),
})?;
let mut builder = SchemaBuilder::new(&protocol);
for op in operations {
match op {
BuildOp::Vertex { id, kind, nsid } => {
builder = builder.vertex(&id, &kind, nsid.as_deref()).map_err(|e| {
WasmError::SchemaBuildFailed {
reason: e.to_string(),
}
})?;
}
BuildOp::Edge {
src,
tgt,
kind,
name,
} => {
builder = builder
.edge(&src, &tgt, &kind, name.as_deref())
.map_err(|e| WasmError::SchemaBuildFailed {
reason: e.to_string(),
})?;
}
BuildOp::Constraint {
vertex,
sort,
value,
} => {
builder = builder.constraint(&vertex, &sort, &value);
}
BuildOp::HyperEdge {
id,
kind,
signature,
parent,
} => {
builder = builder
.hyper_edge(&id, &kind, signature, &parent)
.map_err(|e| WasmError::SchemaBuildFailed {
reason: e.to_string(),
})?;
}
BuildOp::Required { vertex, edges } => {
builder = builder.required(&vertex, edges);
}
}
}
let schema = builder.build().map_err(|e| WasmError::SchemaBuildFailed {
reason: e.to_string(),
})?;
Ok(slab::alloc(Resource::Schema(std::sync::Arc::new(schema))))
}13.5.3 Complete entry point catalog
The WASM boundary exposes far more than the original ten entry points. The full set is organized into six categories.
13.5.3.1 Core Schema and Migration
| Entry Point | Signature | Returns |
|---|---|---|
define_protocol |
(spec: &[u8]) -> Result<u32, JsError> |
Protocol handle |
build_schema |
(proto: u32, ops: &[u8]) -> Result<u32, JsError> |
Schema handle |
check_existence |
(proto: u32, src: u32, tgt: u32, mapping: &[u8]) -> Vec<u8> |
MessagePack report |
compile_migration |
(src: u32, tgt: u32, mapping: &[u8]) -> Result<u32, JsError> |
Migration handle |
lift_record |
(migration: u32, record: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack record |
get_record |
(migration: u32, record: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack view+complement |
put_record |
(migration: u32, view: &[u8], complement: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack record |
compose_migrations |
(m1: u32, m2: u32) -> Result<u32, JsError> |
Migration handle |
free_handle |
(handle: u32) |
(void) |
13.5.3.2 Schema Analysis
| Entry Point | Signature | Returns |
|---|---|---|
diff_schemas |
(s1: u32, s2: u32) -> Vec<u8> |
MessagePack diff |
diff_schemas_full |
(s1: u32, s2: u32) -> Vec<u8> |
Full MessagePack diff |
classify_diff |
(proto: u32, diff_bytes: &[u8]) -> Vec<u8> |
MessagePack compat report |
report_text |
(report_bytes: &[u8]) -> Result<String, JsError> |
Human-readable text |
report_json |
(report_bytes: &[u8]) -> Result<String, JsError> |
JSON string |
normalize_schema |
(schema_handle: u32) -> Result<u32, JsError> |
Normalized schema handle |
validate_schema |
(schema_handle: u32, proto: u32) -> Result<Vec<u8>, JsError> |
MessagePack validation report |
13.5.3.3 JSON and Instance Operations
| Entry Point | Signature | Returns |
|---|---|---|
lift_json |
(migration: u32, json_bytes: &[u8], root_vertex: &str) -> Result<Vec<u8>, JsError> |
MessagePack lifted JSON |
get_json |
(migration: u32, json_bytes: &[u8], root_vertex: &str) -> Result<Vec<u8>, JsError> |
MessagePack view+complement |
put_json |
(migration: u32, view: &[u8], complement: &[u8], root_vertex: &str) -> Result<Vec<u8>, JsError> |
MessagePack restored JSON |
validate_instance |
(schema: u32, instance: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack validation report |
instance_to_json |
(schema: u32, instance: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack JSON bytes |
json_to_instance |
(schema: u32, json: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack instance bytes |
json_to_instance_with_root |
(schema: u32, json: &[u8], root: &str) -> Result<Vec<u8>, JsError> |
MessagePack instance bytes |
instance_element_count |
(instance: &[u8]) -> Result<u32, JsError> |
Node count |
parse_instance |
(registry: u32, format: &str, data: &[u8], ...) -> Result<Vec<u8>, JsError> |
MessagePack instance bytes |
emit_instance |
(registry: u32, format: &str, instance: &[u8], ...) -> Result<Vec<u8>, JsError> |
Serialized output bytes |
13.5.3.4 Protolens and Lens Operations
| Entry Point | Signature | Returns |
|---|---|---|
auto_generate_protolens |
(schema1: u32, schema2: u32) -> Result<u32, JsError> |
ProtolensChain handle |
check_lens_laws |
(migration: u32, instance: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack law check |
check_get_put |
(migration: u32, instance: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack law check |
check_put_get |
(migration: u32, instance: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack law check |
invert_migration |
(mapping: &[u8], src: u32, tgt: u32) -> Result<Vec<u8>, JsError> |
MessagePack inverse |
compose_lenses |
(l1: u32, l2: u32) -> Result<u32, JsError> |
Migration handle |
instantiate_protolens |
(chain: u32, schema: u32) -> Result<u32, JsError> |
Migration handle |
protolens_complement_spec |
(chain: u32, schema: u32) -> Result<Vec<u8>, JsError> |
MessagePack spec |
protolens_from_diff |
(diff: &[u8], s1: u32, s2: u32) -> Result<u32, JsError> |
ProtolensChain handle |
protolens_compose |
(c1: u32, c2: u32) -> Result<u32, JsError> |
ProtolensChain handle |
protolens_chain_to_json |
(chain: u32) -> Result<Vec<u8>, JsError> |
JSON bytes |
protolens_from_json |
(json: &[u8]) -> Result<u32, JsError> |
ProtolensChain handle |
protolens_fuse |
(chain: u32) -> Result<u32, JsError> |
ProtolensChain handle |
protolens_lift |
(chain: u32, morphism: &[u8]) -> Result<u32, JsError> |
ProtolensChain handle |
protolens_check_applicability |
(chain: u32, schema: u32) -> Result<Vec<u8>, JsError> |
MessagePack report |
protolens_fleet |
(chain: u32, schemas: &[u32]) -> Result<Vec<u8>, JsError> |
MessagePack fleet result |
apply_protolens_step |
(protolens: &[u8], schema: u32) -> Result<u32, JsError> |
Schema handle |
factorize_morphism |
(morphism: &[u8], domain: u32, codomain: u32) -> Result<u32, JsError> |
ProtolensChain handle |
symmetric_lens_from_schemas |
(s1: u32, s2: u32) -> Result<u32, JsError> |
SymmetricLens handle |
symmetric_lens_sync |
(lens: u32, dir: &str, data: &[u8], state: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack sync result |
13.5.3.5 Expression & Query
| Entry Point | Signature | Returns |
|---|---|---|
parse_expr |
(source: &str) -> Vec<u8> |
MsgPack Expr |
eval_func_expr |
(expr: &[u8], env: &[u8]) -> Vec<u8> |
MsgPack Literal |
execute_query |
(query: &[u8], instance: &[u8]) -> Vec<u8> |
MsgPack results |
parse_expr tokenizes and parses Haskell-style source text into an AST. eval_func_expr evaluates an expression with environment bindings. execute_query runs a declarative query against an instance.
13.5.3.6 GAT Engine
| Entry Point | Signature | Returns |
|---|---|---|
create_theory |
(spec: &[u8]) -> Result<u32, JsError> |
Theory handle |
colimit_theories |
(t1: u32, t2: u32, shared: u32) -> Result<u32, JsError> |
Theory handle |
check_morphism |
(morphism: &[u8], domain: u32, codomain: u32) -> Result<Vec<u8>, JsError> |
MessagePack result |
migrate_model |
(model: &[u8], morphism: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack model |
list_builtin_protocols |
() -> Vec<u8> |
MessagePack protocol list |
get_builtin_protocol |
(name: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack protocol |
register_io_protocols |
() -> u32 |
Registry handle |
list_io_protocols |
(registry: u32) -> Result<Vec<u8>, JsError> |
MessagePack list |
13.5.3.7 VCS and Data Versioning
| Entry Point | Signature | Returns |
|---|---|---|
vcs_init |
(protocol_name: &[u8]) -> u32 |
Repo handle |
vcs_add |
(repo: u32, schema: u32) -> Result<Vec<u8>, JsError> |
MessagePack add result |
vcs_commit |
(repo: u32, message: &[u8], author: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack commit hash |
vcs_log |
(repo: u32, count: u32) -> Result<Vec<u8>, JsError> |
MessagePack log entries |
vcs_status |
(repo: u32) -> Result<Vec<u8>, JsError> |
MessagePack status |
vcs_diff |
(repo: u32) -> Result<Vec<u8>, JsError> |
MessagePack diff |
vcs_branch |
(repo: u32, name: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack result |
vcs_checkout |
(repo: u32, target: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack result |
vcs_merge |
(repo: u32, branch: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack result |
vcs_stash |
(repo: u32) -> Result<Vec<u8>, JsError> |
MessagePack result |
vcs_stash_pop |
(repo: u32) -> Result<Vec<u8>, JsError> |
MessagePack result |
vcs_blame |
(repo: u32, vertex: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack blame info |
store_dataset |
(schema: u32, data: &[u8]) -> Result<u32, JsError> |
Dataset handle |
get_dataset |
(dataset: u32) -> Result<Vec<u8>, JsError> |
MessagePack dataset |
migrate_dataset_forward |
(dataset: u32, migration: u32, ...) -> Result<u32, JsError> |
Dataset handle |
migrate_dataset_backward |
(dataset: u32, migration: u32, ...) -> Result<u32, JsError> |
Dataset handle |
check_dataset_staleness |
(dataset: u32, schema: u32) -> Result<Vec<u8>, JsError> |
MessagePack staleness |
store_protocol_definition |
(protocol: &[u8]) -> Result<u32, JsError> |
Handle |
get_protocol_definition |
(handle: u32) -> Result<Vec<u8>, JsError> |
MessagePack protocol |
get_migration_complement |
(complement: &[u8]) -> Result<Vec<u8>, JsError> |
MessagePack complement |
check_existence, diff_schemas, and diff_schemas_full never return errors at the WASM boundary. Instead, they encode error information inside the returned MessagePack report. This simplifies JS-side error handling for operations where “failure” is an expected, structured result.
13.6 Data flow: JS to Rust and back
The complete lifecycle of a call across the WASM boundary:
The key property: wasm_bindgen passes byte slices as zero-copy views into WASM linear memory. Copies happen only during MessagePack encode (JS side) and decode (Rust side), plus the reverse on the way back.
13.7 Slab lifecycle
Handles follow a simple lifecycle. Freed slots are reused on subsequent allocations:
stateDiagram-v2
[*] --> Allocated: alloc(resource)
Allocated --> InUse: with_resource(handle, f)
InUse --> Allocated: callback returns
Allocated --> Freed: free(handle)
Freed --> Allocated: alloc reuses slot
Freed --> [*]
On the TypeScript side, WasmHandle wraps each raw u32 handle and calls free_handle in its Symbol.dispose implementation. A FinalizationRegistry acts as a safety net: if a WasmHandle is garbage-collected without being disposed, the registry calls free_handle to prevent leaks.
13.8 Error handling
Errors are represented as WasmError variants (an internal thiserror enum) and converted to JsError at the boundary via Into. The JS side receives them as standard Error objects. The five error variants are:
DeserializationFailed: MessagePack input could not be decodedInvalidHandle: theu32index does not point to a live resourceTypeMismatch: the resource at a handle is the wrong variant (e.g., expected Protocol, got Schema)SchemaBuildFailed: aSchemaBuilderoperation failed validationMigrationFailed/LiftFailed/PutFailed: runtime operation errors
The slab slot contains None, so with_resource returns Err(WasmError::InvalidHandle). The JS side receives a standard Error with message “invalid handle: 42”. No undefined behavior or silent corruption.
13.9 Adding a new entry point
To add a new WASM entry point:
- Add a
#[wasm_bindgen] pub fntocrates/panproto-wasm/src/api.rs - If the function needs a new resource type, add a variant to
Resourceinslab.rsand a correspondingas_*extractor - Add the TypeScript signature to
WasmExportsinsdk/typescript/src/types.ts - Wrap the call in a typed method on the appropriate SDK class
- Add an integration test in
tests/integration/tests/wasm_boundary.rs
The check_existence entry point demonstrates the pattern for functions that should never throw: wrap the inner logic in a fallible helper, and convert errors into a structured report in the outer function.