Using Stof in Your App
Stof runs inside your app, as a library. Your app creates documents, puts data and logic into them, calls their functions, and reads the results. This page covers each of those steps. The examples are TypeScript; Python and Rust work the same way, with the method names listed at the end.
Loading a Document
StofDoc.new creates a document from Stof source, a JavaScript object, or bytes:
import { StofDoc } from '@formata/stof';
const doc = await StofDoc.new(`
pricing: {
rate: 2
fn total(units: int) -> float { units * self.rate }
}
`);
A document can keep taking in more, in any format it knows. Each parse adds to what's already there:
doc.parse({ customer: { name: 'Ada', plan: 'pro' } }); // a JavaScript object
doc.parse('limits:\n calls: 100\n', 'yaml'); // YAML (also 'json', 'toml', ...)
doc.parse(rulesFromAModel); // more Stof: data, functions, or both
That's how data and logic from different places end up in one document. See Formats for every format.
Calling Functions
call takes a path to a function and its arguments, and returns what the function returns:
await doc.call('pricing.total', 21); // 42
await doc.call('pricing.total', '4'); // 8: arguments are cast to the parameter's type
Paths start at the document's top level, so 'pricing.total' is the total function on the pricing object. Calls are async because a document can do async work, like waiting on one of your functions.
Running Code Without Keeping It
Sometimes you have logic to run once, like a check or a script from a model, and don't want it in the document afterward. callSource parses a function, calls it as if it were defined at the top level of the document (or on the object you pass), and removes it after:
await doc.callSource(`
fn quote(qty: int) -> float {
self.pricing.total(qty) * self.discount()
}
fn discount() -> float { 0.9 }
`, [10]); // the first function is called; the rest are helpers it can reach through self
Changes the function makes to the document stay; the function and its helpers don't. To take something out of the document yourself, remove takes a path, like drop in Stof:
doc.remove('pricing.old_total'); // a function or field
doc.remove('customers.c_42'); // an object
doc.remove('order.customer', true); // shallow: just the field, not the object it points to
Running by Attribute
When you don't know what a document's functions are called, tag them. run calls every function with an attribute:
const doc = await StofDoc.new(`
ticket: { text: 'Where is my refund?' }
#[triage]
fn route() {
if (self.ticket.text.lower().contains('refund')) self.ticket.team = 'billing';
}
`);
await doc.run('triage'); // every #[triage] function
doc.get('ticket.team'); // 'billing'
This is the usual setup when logic comes from somewhere else, like a model or a customer. The attribute is the agreement: your app runs #[triage] functions, and the document can have as many as it needs, with any names. run() with no attribute runs #[main] functions.
Reading and Writing Values
get and set read and write any value by path:
doc.get('pricing.rate'); // 2
doc.set('pricing.rate', 3);
doc.set('customer.vip', true); // creates the field if it isn't there
Numbers, strings, booleans, lists, and maps come back as JavaScript values. An object comes back as its ID, a string you can pass to the functions that take a node (like blobify below).
Giving Documents Your Functions
A document can only call what it's given. lib adds one of your functions as a library function the document can call:
doc.lib('App', 'notify', (message: string) => notifications.send(message));
// async functions: pass true, and the document can await them
doc.lib('App', 'customer_name', async (id: number) => (await db.customers.find(id)).name, true);
fn on_refund(id: int) {
const name = await App::customer_name(id);
App::notify(`Refund started for ${name}`);
}
This is how you connect a document to the rest of your app on your terms: it gets these functions and nothing else. See Adding Your Own in Libraries.
What a Document Can Reach
A document can read and change itself and call library functions. It can't reach the file system, environment variables, other files, or the network unless you allow it:
doc.allowHttp(['api.example.com']); // network access to these hosts only
doc.allowHttp(); // network access to any host
In Python and Rust, allow_system() also gives access to files and environment variables. The JavaScript package has no file system access at all. Libraries has the full table.
Each call also has a time limit, 2 minutes by default, so a document that loops forever can't hang your app:
doc.setMaxExecutionTime(500); // milliseconds, or null for no limit
execution timed out (max execution time exceeded)
at root.spin (defined at 1:1)
Errors
A function that throws makes call or run reject, with a plain message and the Stof call stack:
try {
await doc.call('pricing.total');
} catch (error) {
console.log(String(error)); // missing argument 'units' for total(units: int)
}
A document that doesn't parse throws from new or parse, with the line and column:
parse error: unexpected end of input (expected '}')
--> line 1, column 5
|
1 | a: {
| ^
For line numbers on runtime errors too, parse with the test profile: StofDoc.new(src, 'stof', 'test'). The default profile leaves them out to keep documents smaller.
These messages are written to be read by people and by models. When a model wrote the document, pass the error back to it and ask for a fix.
Exporting
A document exports to any format it knows:
doc.stringify('json'); // a JSON string (functions are left out)
doc.stringify('yaml');
doc.record(); // a plain JavaScript object
To keep the functions, export binary Stof (bstf). It holds the whole document, or one object, and loads anywhere Stof runs:
const bytes = doc.blobify('bstf', doc.get('pricing')); // just the pricing object, functions included
const copy = await StofDoc.new(bytes, 'bstf');
await copy.call('total', 5); // 10
Store those bytes, send them over HTTP or a WebSocket, and run them in another service, a browser, or Python.
Cleaning Up
In JavaScript, a document lives in WebAssembly memory, which the garbage collector doesn't free. Call doc.dispose() when you're done with a document you created for a short task.
Python and Rust
The same steps, by language:
| Step | JavaScript | Python | Rust |
|---|---|---|---|
| Create | await StofDoc.new(src) | doc = Doc() then doc.parse(src) | Graph::default() then graph.parse_stof_src(src, None, Default::default()) |
| Parse other formats | doc.parse(src, 'yaml') | doc.string_import(src, 'yaml') | graph.string_import("yaml", src, None, &Default::default()) |
| Call | await doc.call('pricing.total', 21) | doc.call('pricing.total', [21]) | Runtime::call(&mut graph, "pricing.total", vec![Val::from(21i64)]) |
| Call source | await doc.callSource(src, [21]) | doc.call_source(src, [21]) | Runtime::call_source(&mut graph, src, None, vec![Val::from(21i64)]) |
| Remove | doc.remove(path) | doc.remove(path) | graph.remove(path, false) |
| Run by attribute | await doc.run('triage') | doc.run('triage') | graph.run(Some("triage".into()), true) |
| Read and write | doc.get(path), doc.set(path, value) | doc.get(path), doc.set(path, value) | the crate docs |
| Host functions | doc.lib('App', 'notify', fn) | doc.lib('App', 'notify', fn) | graph.insert_libfunc(LibFunc { .. }) |
| Network access | doc.allowHttp(hosts) | doc.allow_http_hosts(hosts) | graph.allow_http_hosts(hosts) |
| Files and environment | not available | doc.allow_system() | graph.allow_system() |
| Time limit | doc.setMaxExecutionTime(ms) | doc.set_max_execution_time(seconds) | graph.max_execution_time = Some(duration) |
| Export | doc.stringify('json'), doc.blobify('bstf') | doc.string_export('json'), doc.binary_export() | graph.string_export("json", None), graph.binary_export("bstf", None) |
The same document gives the same results in all three.