Summary#
Every body accumulates its writes in a unit of work rather than sending them one at a time.
UnitOfWork.Commit() makes everything accumulated so far durable, atomically — all of it lands or none of it
does — and UnitOfWork.Discard() throws that accumulation away instead. Reads inside the same body already see the
pending writes, so nothing needs saving before it can be used.
Signature#
UnitOfWork.Commit() // persist everything accumulated, atomically
UnitOfWork.Discard() // drop everything accumulated, in THIS unit of work onlyDescription#
What a unit of work is#
Writing new Order { … }, assigning a property, or calling .Delete() does not talk to the database. It records
the change in the unit of work that the current body is running inside. UnitOfWork.Commit() is what sends the
accumulated changes, and it sends them as one atomic act: if any part fails — an invariant, a constraint, a
security rule — nothing is written.
That is the reason the verb names the unit of work rather than the row. There is no per-row save, because a save is never about one row: it is about everything the body has done so far.
Reads already see pending writes#
Every read answers about the rows the database holds together with the changes this unit of work has staged.
That is the whole rule, and it holds for all three kinds of change — a row created, a row deleted, a field edited —
and for every way of asking: ToList, Count, Any, All, Sum, Min, Max, Average, First, Last,
Single, Where, OrderBy with Take/Skip, through a parent's child collection (report.Lines) or as a query
you write out yourself. There is no member that reads only the committed data, so there is nothing to remember about
which one you are holding.
A row you have created or modified is therefore visible to the rest of the body immediately, including through queries. This is the property that makes a body readable — you write what you mean in the order you mean it, and the last line makes it durable:
entity Invoice {
[Required, MaxLength(20)] string Code;
decimal Total;
security { allow create, read, update when IsAuthenticated || IsAnonymous; }
}
decimal AddAndTotal(string code, decimal amount) {
var inv = new Invoice { Code = code, Total = amount };
// Not committed yet — and already found by an ordinary query, because the query
// reads the unit of work's view of the data, not the database's.
var sum = Invoice.Sum(i => i.Total);
UnitOfWork.Commit();
return sum;
}So you never need to commit in order to read your own work back, and doing so to "make the total come out right" is a workaround for nothing — it also splits one atomic act into two, which is a real loss: after the first commit, a later failure can no longer undo what the first one wrote.
The one thing worth knowing is where the work happens. With nothing staged, a total is computed by the database
— one SELECT SUM(…), no rows sent back. Once this unit of work is holding an unsaved change to that data the
database can no longer answer correctly (it has not been told), so the rows are read and the total is worked out
here instead. Measured on 6,000 rows: 1.2 ms folded, 18.2 ms once a single row is dirty. That is paid only while
something is unsaved and only for the type you changed — but it is the reason to keep a body's writes and its
big reports apart rather than interleaving them.
Committing is not automatic, and nothing warns you at run time#
A body that writes and never commits loses the write silently. There is no exception and no log line: the change was recorded in a unit of work that was then discarded. In a UI this is especially convincing, because the screen updates from the pending write and looks saved.
The platform therefore catches it before run time instead. data-write-never-committed is a MUST-tier lint
finding that names the verb that writes and where the write is; osy validate prints it in full as
DATA_NEVER_SAVED even when the source is otherwise green, and osy test prints it under a green tally, because a
test that only looks at the screen passes. It stays quiet as soon as something in that flow commits — so the two
correct shapes below both satisfy it.
The commonest way in is a function taking a ROW, called from a page: it joins the page's unit of work rather than committing when it returns (see function). Pages are getting an explicit unit-of-work mode; until then, commit in the function or the action.
The two correct shapes#
Commit per act. Ticking a to-do is the save; each verb is a complete act and commits for itself.
entity Todo {
[Required, MaxLength(120)] string Title;
bool IsDone;
security { allow create, read, update when IsAuthenticated || IsAnonymous; }
}
void ToggleTodo(Todo t) {
t.IsDone = !t.IsDone;
UnitOfWork.Commit();
}Commit once, at the end. A form accumulates freely and commits when the user saves. Every write between the first and the commit is part of the same atomic act — which is what makes a half-saved form impossible.
entity Customer {
[Required, MaxLength(120)] string Name;
security { allow create, read, update when IsAuthenticated || IsAnonymous; }
}
entity Address {
[Required] Customer Owner;
[Required, MaxLength(200)] string Line1;
security { allow create, read, update when IsAuthenticated || IsAnonymous; }
}
void Register(string name, string line1) {
var c = new Customer { Name = name };
var a = new Address { Owner = c, Line1 = line1 };
UnitOfWork.Commit(); // both rows, or neither
}Throwing the accumulation away — UnitOfWork.Discard()#
Discard() is the other half of the same decision: it drops everything the unit of work has accumulated instead of
persisting it. The rows return to what the server last confirmed, and the body carries on.
entity Draft {
[Required, MaxLength(200)] string Body;
security { allow create, read, update when IsAuthenticated || IsAnonymous; }
}
void StartOver(Draft d, string replacement) {
d.Body = "";
UnitOfWork.Discard(); // that edit is gone
d.Body = replacement; // this one is not — the unit of work continues
UnitOfWork.Commit();
}A row the discarded unit of work created is a different matter from one it merely edited. The edit above has
something to fall back to — the value the server last confirmed — but a row that only ever existed in the discarded
work has nothing: it never reached the database and never will. Reading or writing one is refused by name rather
than answered with null or staged where nothing will commit it:
var d = new Draft { Body = "…" };
UnitOfWork.Discard();
d.Body = "changed"; // refused: the Draft row … was created and then DISCARDED"…everything this unit of work had staged was thrown away — by an explicit UnitOfWork.Discard(), or by the
cleanup that follows a Throws/Denied assert catching a commit fault." Create it after the discard, or keep
the work you want out of the unit of work you are about to throw away.
Commit and Discard are deliberately not symmetric, and the asymmetry is load-bearing. A commit reaches
OUTWARD — persisting is the outermost unit of work's job, so an inner scope's edits have to reach it. A discard
clears exactly ONE unit of work, the one you are in, and stops. If it reached outward too, closing an inner surface
would take the surrounding page's unsaved work with it.
Failure leaves nothing behind#
If a commit is refused, the writes it carried are gone — including the ones that were individually valid. A body that wants to record something about the failure must do that work after catching it, and commit again; see try / catch / finally for the worked example.
The one exception is a page's own save. A UnitOfWork.Commit() written in an action of a page, or of a component
on it (a SaveBar, a Save button), is the person's save of what they edited: refused, it keeps every edit on the page,
still unsaved and still shown, so they fix what the refusal names and save again ([[ui-page-unit-of-work#held-save]]).
A commit inside a function — the page's or a kit's — keeps the rule above.
Where it runs#
UnitOfWork.Commit() is a server act. Called from a UI action it hands off to the server, persists, and returns —
the client's pending edits become durable at that moment. Nothing about the spelling changes between a server
function and a UI action, which is the point: the same sentence means the same thing in both. See
Crossing the network for how a body is split, and creating & saving data for the UI-facing story of building
a form around it.
See also#
- creating & saving data — creating and saving data from a UI action, and choosing between the two shapes above
- entity — the entities a unit of work persists
- try / catch / finally — what survives a refused commit, and how to record the failure
- Crossing the network — why a commit is a server act even when written in a client body
- UnitOfWork savepoints — Mark, Undo, Redo, RollbackTo — Mark / Undo / Redo / RollbackTo: stepping back through the accumulation instead of throwing all of it away