Writing records from Rust

A Rust function writes documents through the same calls a JavaScript function uses.

Docs menu

The substrio_wasm::db module in the substrio_wasm crate gives you updates by id and by filter, upserts, batch inserts, replaces, deletes, the JSON body of a query, SQL through a collection, and the declarations that shape a collection. Each call uses the platform write path directly. There is no copy of the document on the way in and no second parse.

An update is built once with Update and handed to the platform as a fixed list of cells. A filter is the query you already build for reads, so a filtered write is the query's last step.

One call for a whole chain

A chain is written as a short program in your module's memory. Each step adds a few bytes without crossing into the platform. The terminal adds an operation and sends the program in one call. The platform decodes it using the same code that builds a JavaScript chain plan, then runs the plan. A ten-step chain takes one call; making a call for each step would take thirteen.

A chain you run again and again with different values can be kept with prepare(). Its steps are decoded once. Each later run passes only its values and its terminal, so no step is decoded twice. Mark a value with db::arg(n) and pass the values with bind.

use substrio_wasm::db::{self, Order, Param};

let orders = db::collection("orders")?;
let recent = orders.gte("total", db::arg(0)).sort("placed", Order::Desc).limit(20).prepare()?;
let big = recent.bind(&[Param::I64(500)]).all(|row| row.to_json())?;
let small = recent.bind(&[Param::I64(10)]).count()?;

bind works on any chain, prepared or not. A later bind replaces the values of an earlier one. Prepared chain steps are fixed at preparation time. Adding a step causes an error on the next run.

A worked example

#![no_std]
extern crate alloc;

use substrio_wasm::db::{self, Update};
use substrio_wasm::{Ctx, Env, Request, Response};

fn fetch(_req: &Request, _env: &Env, _ctx: &Ctx) -> Response {
    let orders = match db::collection("orders") {
        Ok(c) => c,
        Err(e) => return Response::json(500, alloc::format!("{{\"code\":{}}}", e.code)),
    };
    // Every open order older than the cutoff is marked late, in one call.
    let late = orders
        .eq("state", "open")
        .lt("placed", 1_767_225_600_000i64)
        .update(&Update::new().set("state", "late").inc("reminders", 1));
    match late {
        Ok(n) => Response::text(200, &alloc::format!("{} orders marked late", n)),
        Err(e) => Response::json(500, alloc::format!("{{\"code\":{}}}", e.code)),
    }
}

substrio_wasm::export!(fetch);

Updates

Update::new() starts an update, and each method adds one operator. The names follow the JavaScript operators.

Rust JavaScript
set(field, v), set_json(field, json) $set
unset(field) $unset
inc(field, n), mul(field, n), min(field, v), max(field, v) $inc, $mul, $min, $max
push(field, v), push_json, push_each, position, slice $push with $each, $position and $slice
add_to_set(field, v), add_to_set_each $addToSet
pull(field, v), pull_all(field, json), pop(field, end) $pull, $pullAll, $pop
rename(field, to) $rename
current_date(field) $currentDate
set_on_insert(field, v) $setOnInsert
guard(field, Guard::Eq, v) and the other compares the if option of a write

A failed guard leaves the document unchanged. The write returns as if no document had matched.

Collection calls

Rust JavaScript Return value
update(id, &ops) update(id, ops) true when the document took the update
update_row(id, &ops, read) update(id, ops) the row after the write
update_many(&ids, &ops) updateMany(ids, ops) the count written
delete_many(&ids) deleteMany(ids) the count removed
replace(id, doc) replace(id, doc) true when the document was found
insert_many(docs, skip_conflicts) insertMany(docs, {onConflict}) the new ids, in order
add(doc), create(doc, read) add(doc), create(doc) the id, or the stored row
put(doc), put_row(doc, read) put(doc) the id and whether it was inserted, or the row
put_many(&docs) putMany(docs) the ids, in order
upsert_many(&keys, docs) upsertMany(key, docs) the ids, in order
query_sql(sql, &params) query(sql, ...args) the SQL rows
prepare_sql(sql) prepare(sql) a statement

By default, insert_many stores the whole batch or none of it. A unique-field conflict prevents the batch from being stored. If skip_conflicts is set, documents with conflicts are skipped and the rest are stored.

Query calls

Rust JavaScript Return value
update(&ops) update(ops) the count written
update_returning(&ops, read) update(ops, {returning: true}) the rows written
increment(field, n), decrement(field, n) increment, decrement the count written
delete() delete() the count removed
find_one_and_update(&ops, read) findOneAndUpdate(filter, ops) the row after the write
find_one_and_delete(read) findOneAndDelete(filter) the row before it was removed
upsert(&ops, seed) upsert(filter, doc) the id and whether it was inserted
find_or_create(doc) findOrCreate(filter, doc) the id and created
update_or_create(&ops, seed) updateOrCreate(filter, update) the id and created
json(), json_first() json(), jsonFirst() the exact bytes of the JSON body
ids() ids() the ids, in the query's order
last(read) last() the last row
pluck(field, read) pluck(field) one value a row
first_or_fail(read) firstOrFail() the first row
bind(&params) with db::arg(n) in a step db.arg(n) with the terminal's arguments the bound query
prepare() a chain kept to run again the kept query

Walking rows

each(read, then) passes each row to then in query order, with its position in the walk. The walk ends when then returns false. chunk(n, read, then) passes rows in pages of n, with each page's index. Both return the number of rows passed to the callback. The query's ids are taken first, and each row is read when its turn comes, so then may write to the collection. A row removed during the walk is skipped. In each it keeps its position, as JavaScript's index does, and in chunk a page left with no rows is passed over and takes no index. A row added during the walk is not part of it. read makes the value of a row while the row is read, and it may only read.

cursor() walks the collection's documents in id order without a query, as JavaScript's raw collection cursor does. Each call of page(at, n, read) makes one move and answers up to n documents from there, and the cursor then stands on the last of them. The moves are Seek::First, Seek::Last, Seek::Ge(&id), Seek::Le(&id), Seek::Lt(&id), Seek::Next, and Seek::Prev. Last, Le, Lt, and Prev walk backwards. A document that has expired and has not been swept yet is passed over. The cursor is only the id it stands on, so it needs no closing, and a write between two pages is seen by the next page. id() answers the id the cursor stands on. JavaScript's seekPrefix and seekPrefixLast have no Rust form, because a document id is a number with no text for a prefix to match.

let deck = db::collection("deck")?;
let mut seen = Vec::new();
deck.sort("i", Asc).each(|card| card.get("i").and_then(|v| v.as_i64()), |i, _| { seen.push(i); seen.len() < 4 })?;

let mut cur = deck.cursor();
let mut page = cur.page(Seek::First, 100, |r| r.to_json())?;
while !page.is_empty() {
    page = cur.page(Seek::Next, 100, |r| r.to_json())?;
}

An upsert takes the seed you pass, a JSON object holding the filter's equality fields, and lays the update's values into it when no document matches. When the update holds only plain fields, the update itself is the new document.

An upsert takes the collection's upsert gate, the same gate a JavaScript upsert takes, and holds it from the find to the write. Two upserts of one key, from Rust or from JavaScript, never both insert. The second one updates the document the first one inserted. The key is the chain's equality conditions, so eq("email", e) and JavaScript's {email: e} share one gate. A chain with any other condition, or with no condition, takes the gate of the whole collection. When the call's time limit passes before the gate is free, the upsert is refused with TIMEOUT and nothing is written. A Date in the filter is part of the key as a Date, in Rust as in JavaScript, so both share its gate.

upsert_many(&keys, docs) runs an array of documents in one call. Each document is matched on its values of the key fields, which must be strings, numbers, or booleans. A match takes the document's fields, and a miss inserts the document with its $setOnInsert fields laid in. The call holds the gate of the whole collection, so no other upsert runs between a document's find and its write. A document may match one that an earlier document in the same call inserted. The call is all or nothing. When a unique field, a schema, or a quota refuses one document, the call puts every updated document back exactly as it was, removes every document it inserted, and is refused. For the key _id, use put_many.

let people = db::collection("people")?;
let ids = people.upsert_many(&["email"], r#"[{"email":"[email protected]","visits":1},{"email":"[email protected]"}]"#)?;

upsert_many_typed(key, &docs) takes the documents as Update values instead of JSON. Each document holds its fields as set cells and its insert-only fields as set_on_insert cells, and it is matched on its value of the one field key. The call is all or nothing, as upsert_many is. When every field of a document is a scalar set of a field the collection already has, the document takes the same planned path a JavaScript upsertMany takes. Its cells are read where they lie, with no JSON written or parsed. The document is found by one seek of the key's index, and it is then patched in place or inserted from that same read. A document that brings a new field, a nested value, or a set_on_insert takes the general path instead. A document that holds any other operator, or no value for its key, is refused with INVALID_ARGUMENT.

let names: Vec<String> = (0..200).map(|i| format!("user{i}")).collect();
let docs: Vec<Update> = names.iter().enumerate().map(|(i, n)| Update::new().set("email", n.as_str()).set("visits", i as i64)).collect();
let ids = people.upsert_many_typed("email", &docs)?;

The search steps are chain steps like any other. Each one becomes the same search leaf a JavaScript chain adds, and it is run by the same index search. There is no query plan to encode and no JSON on the way in or out.

Rust JavaScript
match_text(field, text, any) match(field, text, {mode})
top(k, weight) top(k, weight)
knn(field, &vector, k) knn(field, vector, k)
near(field, lat, lng, k) near(field, lat, lng, k)
within_box(field, &low, &high), within_sphere(field, &center, radius) withinBox, withinSphere
sparse_knn(field, &indices, &values, k) sparseKnn(field, {indices, values}, k)
text_near(field, text, fuzzy) textNear(field, text, fuzzy)
hops(label, &nodes, &Hops { hops, min_hops, dir, from, to, min_weight, max_weight }), hops(label, &nodes, &Hops::within(n)) hops(label, nodes, {hops, minHops, dir, from, to, minWeight, maxWeight})
search(text, &Search::k(k)) search(text, {k})
search(text, &Search { mode, decay, feedback, strict_model, .. }) the HTTP $search with the same options
all_scored(read) the rows with each row's score

search embeds the text with the collection's RAG configuration and then finds the nearest documents in its vector field. A kept chain embeds its text once, when it is kept. all_scored answers each row with the score of the ranking, which is the squared distance for a nearest search and NaN when no search step ranked the run.

Search also carries the options of the HTTP $search. The mode is SearchMode::Dense, Sparse, or Hybrid, where the hybrid mode fuses the dense, sparse, and text rankings. decay lowers the score of older documents, feedback re-ranks by the collection's feedback signals, and strict_model keeps only documents embedded by the current model. These options travel as typed values into the same structure that the HTTP $search reads its JSON into, so there is no JSON on the way in or out. A search with a mode other than dense, or with any option, is the chain's only step besides limit. That chain answers all, first, all_scored, ids, count, and exists, and all_scored gives NaN scores because the ranking is the order of the rows.

use substrio_wasm::db::{self, Decay, Search, SearchMode};

let notes = db::collection("notes")?;
let hits = notes
    .query()
    .search("quarterly plan", &Search { mode: SearchMode::Hybrid, decay: Some(Decay::new(7.0 * 86_400_000.0)), ..Search::default() })
    .limit(10)
    .all(|row| row.id().to_owned())?;

substrio_wasm::services::search(collection, text, &SearchOptions) corresponds to substrio.search. With field set it runs a text search of that field, and otherwise it runs the search above with a default k of 10.

Relational steps

The relational steps are chain steps too. Each one is decoded into the same plan a JavaScript chain builds, so the same engine runs it. Field names are read straight from your module's memory, and a join names the other collection by its handle, so a run looks up no collection by name.

Rust JavaScript
select(&["a", "b"]) select('a', 'b')
distinct() distinct()
group_by(&["a"]) groupBy('a')
aggregate(Aggregate::Sum, "n", "") sum('n'), and count, avg, min, max alike
having("sum_n", Compare::Gt, 5) having('sum_n', '>', 5)
join(Join::First, other, "local", "foreign", "as") join(name, local, foreign, as), and innerJoin, leftJoin
union(&arm), union_all, intersect, except union(arm), unionAll, intersect, except
and(&[&|q| q.eq("a", 1), ...]), or, not and(q => q.eq('a', 1), ...), or, not
eq_field("a", "b"), ne_field, gt_field, gte_field, lt_field, lte_field eqField('a', 'b') and the rest

A grouped, aggregated, distinct, or compound chain, or one that selects a joined field, returns plain rows. End it in rows() or rows_first(), which return the same sql::Rows that sql::sql returns, or in count(). A joined chain ends in joined(|row, joined| ...), which hands you each row with one joined row per join, in join order, and None where a join found no match. Each joined row is read in place, as any row is. A plain chain keeps all, first, and the rest.

use substrio_wasm::db::{self, Aggregate, Compare, Join};

let orders = db::collection("orders")?;
let people = db::collection("people")?;
let totals = orders
    .query()
    .group_by(&["person"])
    .aggregate(Aggregate::Sum, "amount", "total")
    .having("total", Compare::Gt, 100)
    .rows()?;
let named = orders
    .query()
    .join(Join::First, people, "person", "_id", "who")
    .joined(|order, joined| (order.id().to_owned(), joined[0].as_ref().map(|p| p.id().to_owned())))?;

Inside and, or, and not, a part holds conditions only: the filters, the field compares, and nested groups. A compound's arm takes no search with options. An arm may join other collections, and its rows are the rows its joins keep, as a JavaScript arm's are. An arm may be a kept chain (prepare()). Its steps are copied into the chain once, as an unprepared arm's steps are, and a db::arg(n) in the arm reads the nth argument of the chain's run. The kept chain still runs alone afterwards. A chain that breaks one of these rules is refused with UNSUPPORTED_OPERATION, and so is a plain-rows chain that ends in all or a joined chain that ends in anything but joined or count.

Relations

A relation is declared on a collection once, when your function starts a call. has_many(name, target, key) says that the documents of target whose field key holds a document's id are that document's related documents. has_one declares the same for one document, and belongs_to declares the reverse. The platform keeps the declaration on the collection, in the same place a JavaScript hasMany keeps it, and each call answers a Relation that the chain steps take.

Rust JavaScript
has_many("posts", posts, "author") hasMany('posts', Posts, 'author')
has_one("profile", profiles, "user") hasOne('profile', Profiles, 'user')
belongs_to("author", people, "author") belongsTo('author', People, 'author')
belongs_to_many("tags", tags, links, "post", "tag") belongsToMany(Tags, {through: 'links', as: 'tags', key: 'post', otherKey: 'tag'})
link(&tags, post_id, tag_id), unlink(&tags, post_id, tag_id) post.link('tags', tag), post.unlink('tags', tag)
with(&posts) with('posts')
with_count(&posts) withCount('posts')
with_where(&posts, &chain) with('posts', q => ...)
where_has(&posts), where_none(&posts) whereHas('posts'), whereNone('posts')

belongs_to_many declares a many-to-many relation. Each link is one document in the through collection, which holds this collection's id in key and the target's id in other_key. An empty field name takes the collection's name in the singular with Id, as in JavaScript. link inserts the one document that names both ids, and writes nothing when the two are linked already. unlink deletes every document that names both. Each costs one call: one lookup of the pair by its two fields, then one insert or one delete for each link found.

A chain with with or with_count ends in related(|row, related| ...) or related_first, or in count(). related.rows(k) holds the related documents of the chain's kth with, and related.count(k) holds their count. Each related row is read in place, as any row is.

use substrio_wasm::db::{self, Order::Asc};

let people = db::collection("people")?;
let posts = db::collection("posts")?;
let written = people.has_many("posts", posts, "author")?;
let shelves = people
    .query()
    .with(&written)
    .with_count(&written)
    .sort("name", Asc)
    .related(|person, related| (person.id().to_owned(), related.rows(0).len(), related.count(1)))?;
let authors = people.query().where_has(&written).count()?;

The related documents of every row come from one pass over the relation's key. The key's values are read once and the index is held once for the whole pass, and with_count reads each count from the count table without reading a document. A with_where constraint, or a key whose values are not all plain document ids, runs the related chain once for each row instead, with the row's id as its key. That is the same path every chain takes.

where_has and where_none read the ids the key field holds, straight from its index, and keep the rows whose id is among them. A kept chain (prepare()) keeps the ids it read with the write generation of each collection they came from. A run reads them again only when one of those collections was written since, so a related document added between two runs is seen by the second. A run after no such write uses the kept ids and reads no index. A related collection that expires documents is read at every run, because time hides its documents with no write.

where_has and where_none also take a belongs_to and a belongs_to_many. A belongs_to keeps the rows whose key field names a document of the related collection. A belongs_to_many keeps the rows that a link document names. Where the key field's index does not hold plain document ids, the related rows are read and each row's key is taken from the index or the record. A kept chain keeps these keys in the same way. A belongs_to_many keeps the write generation of the link collection as well, so a link made after the prepare is seen by the next run. The same code reads these keys for JavaScript and for Rust.

These forms are refused with UNSUPPORTED_OPERATION in Rust for now: with of a has_one or a belongs_to (use join), a dotted path such as posts.comments, with_count with a constraint, with beside a join or a grouped chain, and a relation step inside a union's arm or a constraint. A relation name that is not declared is refused with INVALID_ARGUMENT.

Many writes in one call

Batch holds a list of writes and runs them in one call, in order, under one write scope. Consecutive writes to one collection are recorded as one change entry with one timestamp, so replication and the change history receive them together. This holds for every kind of write, so inserts, sets, updates, deletes, and puts to one collection share one entry. Consecutive inserts into one collection go through one insert scope. A write to another collection starts the next entry. Each write answers one word, read straight from your module's memory, so there is no JSON of the list and no JSON answer.

let people = db::collection("people")?;
let bump = Update::new().inc("visits", 1);
let answers = db::Batch::new()
    .insert(people, r#"{"name":"Ada"}"#)
    .update(people, id, &bump)
    .delete(people, old_id)
    .run()?;

run returns one result for each write up to the first one that is refused, and that refused write is the last result. The writes before it stand.

Declarations

text(field), top(field, weight), keyword(field, term_bytes), unique(field), expire(field, after_ms, allow_existing), and drop_expiry() declare the same indexes and rules as their JavaScript twins. has_many, has_one, belongs_to, and belongs_to_many declare relations (see Relations).

compound(&lead, order) declares a compound index. An equality on every lead field and a range or a sort on the order field then read one key range. elements(field, &members) keys each element object of an array field by its members, so an elemMatch of equalities on the first members and one range on the next reads one key range. drop_compound and drop_elements take a declaration away and answer true when it was there. schema(definition) takes the same JSON definition as JavaScript's schema({rules, constraints, strict, existing}). Stored documents that break it make the call fail with SCHEMA_VIOLATION, and its message names the first one. Each declaration is one call, made once when your function starts.

compound_indexes(), element_indexes(), and schema_json() read the declarations back as JSON text. It is the same text JavaScript's compoundIndexes(), elementIndexes(), and schema() parse, and the same entries an index.list request answers. A collection with no declarations answers [], and one with no schema answers null. Each read is one call.

let compounds = people.compound_indexes()?;  // [{"kind":"collection","collection":"people","compound":[...],...}]
let schema = people.schema_json()?;          // null until a schema is declared
Code Name When
0x8000 SCHEMA_VIOLATION schema(definition) over stored documents that break it

Cloudflare shapes

db::D1 has prepare(sql), exec(sql), and batch(statements), and a statement has bind, all, run, first, and raw. db::KvNamespace::open(name) has get, put, delete, and list. Both are written in Rust over the SQL and KV calls, so they cost the same as calling those directly. As in JavaScript, batch runs the statements one after another and is not a transaction.

Refusals

A failed call returns Err(db::Error) with the code JavaScript throws by name.

Code Name When
0x0102 INVALID_ARGUMENT a batch that is not an array of objects, one field named twice in an update, a chain program that is cut short or has bytes after its terminal, a step added to a kept chain, a relation name that is not declared, or a relation declared with an empty name, an empty key, or a name that starts with _, or a page size of 0 for chunk or a cursor's page, or an upsert_many_typed document that holds another operator than set and set_on_insert, or no value for its key
0xC004 COLLECTION_NOT_FOUND a relation's related collection that is no longer there
0xF003 INVALID_JSON a document that is not valid JSON
0x400B INVALID_ID an id that is no document id spelling
0x4000 KEY_NOT_FOUND first_or_fail with no matching document
0x4009 UNIQUE a value a unique field already holds
0x400D INDEX_DUPLICATES a unique declaration over documents that already share a value
0x8000 SCHEMA_VIOLATION a value the collection's schema refuses
0xF009 UNSUPPORTED_OPERATION a $pull with a condition object, a dotted field in an upsert's new document, or a write after a join, with, groupBy, distinct, or a compound step, or each or chunk after a join or with
0x3005 TIMEOUT an upsert whose call's time limit passed before its gate was free; nothing was written

The message JavaScript would carry is kept with the refusal, and substrio_wasm::sql::last_error_message() reads it.

What a call costs

Step Cost
A chain's step No call. Its code and operands are added to the program: about 10 bytes, plus the field name and the value
A chain's terminal 1 call for the whole chain: one bounds check of the program and one of the answer's buffer, then 1 byte load and 1 jump for each op, and the step JavaScript's method makes
A kept chain's run 1 call: its handle (2 loads and 2 compares), its values decoded, and the run. No step is decoded again
An update its cells read in place, with one bounds check for the list and one for each field and value
A filtered write 1 call: the chain's run and the write, with no id leaving the platform
insert_many 1 call: one JSON parse and one insert scope for the whole batch
A search with a mode or options 1 call: the options read once into the structure the HTTP $search reads its JSON into, then the same search
Batch::run 1 call: one change entry for each run of writes to one collection
A relational step No call. Its op is decoded by the plan call JavaScript's method makes: one field lookup for each name
A kept chain as a union's arm No call. Its steps are copied into the program once, with 1 branch more than an unprepared arm takes
A compound chain's rows or count Each arm's collection and joins read by handle, then the compound answered from the index's value keys or the records, as JavaScript answers it. The SQL engine runs only a compound beyond that path
A joined row Its joined rows written as rows are, from the documents the run joined, with no lookup
A relation's declaration 1 call at call setup: one scan of the collection's relations by name
A row of with 2 stores for its id and handle, then one key seek in a pass held once for every row, and each related document read by its id and written as a row is
A row of with_count 2 stores for its id and handle, one count read from the count table, and 1 store of the count
A row of with_where 2 stores for its id and handle, the row's id spelled once, and one run of the constraint's chain with that id as its key
where_has, where_none One walk of the key field's index for the whole chain, then one condition on _id
where_has of a belongs_to or a belongs_to_many One run of the related collection's rows (and of the link rows), their keys read from the index or the records, then one condition, as JavaScript pays
A kept chain's where_has or where_none, nothing written 1 load and 1 compare of the relation's kind, then for the related collection (and a belongs_to_many's link collection) 1 compare, 1 load of its write generation, and 1 compare, then 3 stores of the condition's argument. No index or record is read
A kept chain's where_has or where_none, after a write The read an unprepared chain pays, plus 1 load of each collection's write generation and the expiry test
json() 1 call: the body written once, in the exact bytes JavaScript writes
An upsert's gate, keyed 1 mutex acquire and release, 2 atomic operations, and for each equality 2 hashes, 2 multiplies, 2 shifts, and 1 add, as JavaScript pays
upsert_many 1 call: one gate and one batch for the call, then for each document one key lookup and one update or one insert
upsert_many_typed, a planned document 16 bytes passed for it, then for each field one copy of its cell and 2 compares of its name, then one seek of the key's index and one patch in place or one insert from the same read, as a JavaScript planned document pays
upsert_many_typed, any other document What upsert_many pays for a document, read from its cells instead of from JSON
An upsert's gate, whole collection 1 mutex acquire and release, 1 atomic add and 1 store, and 16 loads, as JavaScript pays
each 1 call for the ids, then 1 read by id for each row, as JavaScript makes one document for each row
chunk 1 call for the ids, then 1 read by id for each page, with every row of the page in that one read
A cursor's page 1 call: one seek, then for each row one step of the tree and the row written as a query's row is. The platform keeps nothing between pages. A JavaScript cursor makes one call for each step and one document for each value

What is not here yet

with over a belongs_to_many and the relation forms listed under Relations are still JavaScript only.

Not in Rust, and why

Two JavaScript features have no Rust form, and none is planned.

Scopes are not lifted. In JavaScript, collection.scope(name, fn) keeps a function that adds steps to a chain, so a chain can call it by name. In Rust, a plain function from Query to Query does the same work. It is compiled into the function, so it costs nothing at run time and has nothing to declare.

Live rows are not lifted. In JavaScript, live(id), a bound Model class, and for...of over a collection hand out objects whose field assignments are written at once. They are built on the JavaScript engine's object model. In Rust, update(id, &ops) makes the same write in one call. With a guard on the update, a write over a document another writer changed is refused, as a live row's write is refused with ROW_STALE.

See also Rust functions, the entry page: when to pick Rust, every call exposed to it, deploy and testing.