Osy#betaRun it on Osyrin: osyrin.com

Reference / Function

UnitOfWork

UnitOfWork.Commit() · UnitOfWork.Discard()

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.

stable4 examples compiled by CIfunctiondataauthoring

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 only

Description#

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#

Related

creating & saving data

A UI `action` creates, updates and deletes data by writing `new Entity { … }`, assigning fields, and calling…

entity

Declares a persisted type — a table of rows the app stores, queries and secures. Every entity gets an Id from the…

try / catch / finally

Handles a fault. C#'s syntax, including typed catches, catch filters and finally. The rows written inside a try block…

UnitOfWork savepoints — Mark, Undo, Redo, RollbackTo

Marks form a stack on the unit of work. UnitOfWork.Mark(label) records everything staged so far and answers a…

Save, discard, undo — three ways to show a page's unit of work

Every edit on a page joins the route's unit of work; how the page OFFERS to keep it is a choice of presentation…