Summary#
Two people open the same record. One saves; the other, still typing, saves after — before their page heard of the
first save. With [[entity-concurrency-check|[ConcurrencyCheck]]] the second save is refused rather than silently
overwriting the first — and on a page, the person whose save was refused is shown what happened instead of only being
told no:
| Yours | Now | |
|---|---|---|
| Note | what they typed | what the row holds now |
…with two buttons: Keep theirs and Apply mine. There is nothing to write for this; it is what a page does when its own save loses a race and nothing in the app catches it.
Signature#
action Save() {
editing.Note = draft;
UnitOfWork.Commit(); // refused when somebody else changed Note after this page last showed it
} // → uncaught: the page shows the conflict noticeDescription#
When it appears#
All three, and only then:
- the write is the page's own — an action sets a member and calls
UnitOfWork.Commit()(or the action's end commits it). A function the page calls on the server writes on what that function read and is not a page edit ([[entity-concurrency-check#whose-read]]); - the member is guarded by
[ConcurrencyCheck], and somebody else changed it after the page last showed it. A change the page WAS shown — aliveread brought it — is not a conflict: the save is based on it and lands, and a box bound to the value has said it changed ([[entity-concurrency-check#changed-elsewhere]]); - nothing catches the
ConflictException. Atry { … } catch (ConflictException e) { … }in the action is the app deciding, and the notice is not shown. An app that drawsFailureitself (Failure — what went wrong the last time an action ran) has taken every failure surface over, this one included.
What it shows#
One line per guarded member the save wrote that somebody else changed: Yours is what the person wrote, Now is what the row holds — read for them, under their own rules, at the moment of the refusal. A member they may not read is named but not shown ("changed — you may not see it"), and then only Keep theirs is offered: a value they cannot see cannot be knowingly overwritten.
The row on the page shows the new value at once, whether or not the page reads it live.
Keep theirs#
The other person's value stands. Everything else the person's save carried — other fields, other rows — goes back as pending edits, exactly as it was before they pressed Save; nothing is committed until they save again.
And the page can save that member again. Its next edit is based on the value the person was shown — so a later save lands, and is refused only if the row moves again.
Apply mine#
The person's whole save is put back and committed again, this time over the value they were shown. It is not a blind overwrite: if the row changed a second time meanwhile, the save is refused again and the notice shows the newer value — the person always writes over something they have seen.
It does not cover what it is about#
The notice sits at the foot of the screen and stays until it is answered. While it is up, the page gets room at its foot as tall as the notice, and the row whose save was refused is scrolled clear of it — so the person can read the row and the notice together, and every row can still be reached.
Styling it#
It is plain HTML with osy- classes, like the other built-in surfaces: .osy-action-failed.osy-conflict for the box,
.osy-conflict-fields for the table (.osy-conflict-yours, .osy-conflict-theirs for the cells),
.osy-conflict-keep and .osy-conflict-apply for the buttons. Restyle them in the app's CSS.
With SaveBar, SaveActions or AutoSave#
The kit's save controls (Save, discard, undo — three ways to show a page's unit of work) commit the page's unit of work the same way, so a save they make that loses a race shows this notice too — they show other refusals themselves ("Not saved — …"), and leave a conflict to it. So does an action of your own that catches everything else and passes the conflict on:
try { UnitOfWork.Commit(); }
catch (ConflictException) { throw; } // the notice shows it
catch (Exception e) { message = e.Message; } // anything else, your sentenceDeciding it yourself instead#
Catch the conflict and the notice is not shown — the app's catch decides, as for any fault ([[entity-concurrency-check#abandon-or-repair]]):
try { editing.Note = draft; UnitOfWork.Commit(); }
catch (ConflictException e) { UnitOfWork.Discard(); message = "Somebody changed that note — have a look first."; }Examples#
[ConcurrencyCheck]
entity Recipe {
[Required, MaxLength(60)] string Title;
[MaxLength(200)] string? Method;
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
[Route("/")]
[AllowAnonymous]
component Recipes() {
var recipes = Recipe.OrderBy(r => r.Title); // read once: a change made after it is a race (see below)
Recipe? editing;
string draft = "";
action Edit(Recipe r) { editing = r; draft = r.Method ?? ""; }
action Save() {
if (editing == null) { return; }
editing.Method = draft;
UnitOfWork.Commit();
editing = null;
}
render {
Stack(gap: 3, p: 6) {
foreach (var r in recipes) {
Row(gap: 2) {
Text($"{r.Title}: {r.Method ?? ""}");
Button("Edit", onPress: () => Edit(r), announceAs: "Edit " + r.Title);
}
}
if (editing != null) {
Stack(gap: 2) {
Field("Method", value: draft);
Button("Save", onPress: Save);
}
}
}
}
}[TestFixture]
void Kitchen() { new Recipe { Title = "Bread", Method = "knead" }; }
[Test(Kitchen)]
void A_lost_race_shows_both_values_and_apply_mine_lands() {
Ui.Visit("/");
Ui.Click("Edit Bread");
Ui.Fill("Method", "fold, do not knead"); // the person starts, from what the page read
var r = Recipe.Single();
r.Method = "knead twice";
UnitOfWork.Commit(); // somebody else's save lands first
Ui.Click("Save"); // the person's save loses the race
Assert.Visible("Somebody else changed this Recipe while you were editing it.");
Assert.TextIs("fold, do not knead"); // Yours
Assert.TextIs("knead twice"); // Now
Ui.Click("Apply mine");
Assert.Visible("Bread: fold, do not knead");
Assert.Equal("fold, do not knead", Recipe.Single().Method);
}
[Test(Kitchen)]
void Keep_theirs_leaves_their_value_and_the_page_can_save_it_again() {
Ui.Visit("/");
Ui.Click("Edit Bread");
Ui.Fill("Method", "fold");
var r = Recipe.Single();
r.Method = "knead twice";
UnitOfWork.Commit();
Ui.Click("Save");
Ui.Click("Keep theirs");
Assert.Equal("knead twice", Recipe.Single().Method);
Ui.Click("Edit Bread");
Ui.Fill("Method", "knead twice, then rest");
Ui.Click("Save");
Assert.Visible("Bread: knead twice, then rest");
}using Osysharp.Ui;
[ConcurrencyCheck]
entity Lamp {
[Required, MaxLength(60)] string Name;
[MaxLength(200)] string? Fuel;
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
[Route("/lamps/{id}")]
[Render(CSR)]
[AllowAnonymous]
component LampPage(Guid id) {
var lamp = Lamp.FirstOrDefault(p => p.Id == id); // read once, so a save after another one is a race
render {
if (lamp != null) {
Stack(gap: 3, p: 6) {
Text(lamp.Name + ": " + (lamp.Fuel ?? ""));
Field("Fuel", value: lamp.Fuel);
SaveBar(saved: "Kept.");
}
}
}
}
[Test]
void A_lost_race_under_a_save_bar_shows_both_values() {
var hall = new Lamp { Name = "Hall lamp", Fuel = "paraffin" };
UnitOfWork.Commit();
Ui.Visit("/lamps/" + hall.Id);
Ui.Fill("Fuel", "olive oil"); // the person starts, from what the page read
var other = Lamp.Single();
other.Fuel = "lamp oil";
UnitOfWork.Commit(); // somebody else's save lands first
Ui.Click("Save");
Assert.Visible("Somebody else changed this Lamp while you were editing it.");
Assert.TextIs("olive oil"); // Yours
Ui.Click("Apply mine");
Assert.Equal("olive oil", Lamp.Single().Fuel);
}See also#
- ConcurrencyCheck — the attribute, and who the writer is when a page calls a server function
- Failure — what went wrong the last time an action ran — the other built-in failure surface, and how an app takes it over
- UnitOfWork — the page's unit of work, and what a commit carries
- try / catch / finally — catching the conflict to decide yourself