Mutations
elements man livetable/mutations Read as markdownInserting, updating, and deleting through a LiveView, including optimistic updates.
Mutators are sync-style and optimistic. The browser updates immediately, the server reconciles asynchronously, and on failure the optimistic UI update is reverted.
The three calls live on the LiveView, the object a route opened with
table.view() and handed to the template. They work the same on the server, so
a write can run from a route, an @rpc, a job, or a test.
comments.insert({ text: "hi", author: "alice" }, () => form.text = "");
comments.update({ ...comment, text: "edited" }, () => setEditing(false));
comments.delete(comment);
insert, update, and delete accept a resetUI callback as a second
argument. It fires as soon as the optimistic change is applied locally, before
the server has acknowledged. Use it to clear the state that drove the mutation:
form inputs, the row that was being edited, any draft data tied to that action.
It is for resetting UI state only, not for navigation or other side effects.
Do not navigate or redirect from resetUI. The callback runs before the write
reaches the server. Navigating there tears the page down and aborts the write,
so the row never reaches the database. Put navigation on the line after the
mutation instead. The mutators are sync-style, so the compiler awaits the write
before the next line runs:
posts.insert({ title, url });
redirect("/");
For a create-then-navigate flow with no on-screen list to update, use a plain
@rpc that returns the new row rather than LiveView.insert. The optimistic
insert is built for adding a row to a list the user is currently looking at; a
submit page that creates one record and leaves has no such list, and the @rpc
avoids the optimistic machinery entirely. That RPC can call redirect()
itself, which removes the timing question: the browser navigates only once the
call settles, so the write is committed before the page is torn down.
insertAsync, updateAsync, and deleteAsync are explicit-promise variants
for genuine promise control (for example, Promise.all over several writes).
They are not a fix for a mutation that seems not to persist. That is almost
always navigation firing too early, addressed by the pattern above.
Including Every Displayed Field
The optimistic row renders before the server reconciles. It holds only the
object you pass to insert(...), nothing else. If the template displays
comment.author, an optimistic insert({ text }) renders undefined for
author until the server's reply arrives. Pass every field the template reads
at insert time, even if the server canonicalizes them.
// browser shows undefined author until reconcile
comments.insert({ text: form.text });
// browser shows the author immediately
comments.insert({ text: form.text, author: session.get("userName")! });
This includes server-generated columns the template reads: timestamps like
createdAt (default now()), sequence numbers, and anything a database trigger
fills in. Those values exist only after the row round-trips. A displayed
createdAt is the common trap: new Date(undefined) renders Invalid Date for
the first frames, then corrects itself once the broadcast lands, which reads as
a flicker with no obvious cause. Pass a client-side placeholder that the
server's real value reconciles a moment later.
comments.insert({
text: form.text,
author: session.get("userName")!,
createdAt: new Date(),
});
The partition columns are the one thing you can leave out. A view opened with
view({ postId }) fills postId in for you.
The same call on both sides
insert, update, and delete are on the LiveView, and they are isomorphic.
The same call means the same thing in a template, a route, an @rpc, a job, and
a test, and it ends at the same row either way:
// in a template, on the browser. `comments` is the view the route passed in
comments.insert({ text: draft, author: session.getOrThrow("userName") });
// in a test, on the server. same call, on a view the test opened
test("a member can comment", () => {
session.login({ userId: user.id, userName: "alice" });
let comments = commentsTable.view();
comments.insert({ text: "hi", author: "alice" });
equal(sql(`select count(*) from comments`).firstOrThrow().count, 1);
});
A write factored into services.ts takes the view as a parameter, so the
template hands it the one the route opened and a test hands it one of its own:
export function postComment(view: LiveView<Comment>, text: string) {
view.insert({ text, author: session.getOrThrow("userName") });
}
What differs is only what each side can do. The browser puts the row in its local list first and syncs after, so the UI moves before the round trip. The server has no list on screen to update, so it runs the authoritative path directly: partition check, your handler or auto-SQL, then the broadcast. Both sides mint the row id and fill in the partition columns before the write, so a handler sees the same shape whichever side called it.
resetUI is accepted on the server for signature parity and never called;
there is no optimistic update to roll back.
A server-side write runs your handler against the current session, which is anonymous unless the caller logged one in, so a test asserting that an anonymous write is refused is a test you can write. It also broadcasts, so browsers watching the table see the row arrive exactly as they would for a browser-initiated write.
What a write returns
insert returns the row that went in, id included, so a handler that needs
the new id (to focus it, to navigate to it) reads it off the return value.
update patches the fields onto the row already held and returns that row, so
it keeps its identity and its dom. delete returns the item you passed, for an
undo affordance.
A write through a partitioned view that names a different partition, or a row the view does not hold, is refused before anything is sent.