🔒 Document save and read-only subtypes — for the developer
Updated
← Developer wiki · The posting boundary
The rule people walk into without reading the code: a field the server has stamped is obliged to come back into the caller's object. Otherwise the document locks completely.
How it works#
Three mechanisms add up to one behaviour:
DocumentManager.SaveDocumentAsyncnarrows the UPDATE down to the columns that provably differ from the stored row (kv.Value.Equals(stored)). The set of columns is assembled out of the CALLER's object; the before-save handler gets a copy.DataService.EnsureDocumentWritableAsynclets a write through on a subtype withIsReadOnlyonly when every key is inTransitionColumns—SubtypeandStatusValue. This allows the idiomdoc.Subtype = "…"; SaveDocumentAsync(doc).- A handler may stamp a header field —
DueDateout of the payment terms, say.
If the stamped value does not come back to the caller, the next save carries
the stale column. The write is no longer "a transition only", and the read-only
subtype refuses it — a purchase order cannot leave Received, a mistaken
receipt cannot be corrected.
What the platform does#
After the write (on an insert as well as on an update) SaveDocumentAsync
re-reads the row and hands the caller everything the server decided to store:
SyncServerAssignedHeader compares what was sent with what is in the row and
carries the difference — by construction, exactly the handlers' edits plus the
data layer's normalization — onto the entity. A value that could not be
coerced to the property's type is left as it is: the re-read is a convenience,
and it has no right to turn a successful save into an exception.
On an update the re-read is done ONLY when there was a write
(changed.Count > 0): a repeat save with no changes is not worth an extra
read.
Inserts echo Number and StatusValue for the same reason: a freshly created
entity would otherwise report null while the row already carries the real
value. ID and Number sit in NonHeaderMembers on top of that, so they are
not sent with the header at all.
A partial update: the handler sees the whole record#
Most updates are partial. SaveDocumentAsync writes only the columns that
differ, SetSubtypeAsync writes only Subtype, and IDataService.UpdateAsync
writes only what it is given. A typed handler that received only those columns
would see zeros everywhere else and have to re-read the document
(MergeStoredHeaderAsync in Sales and CRM) to guess which zero was real.
DataService.UpdateAsync therefore reads the stored row (only when the table
has a handler) and the event carries it:
| What | Where |
|---|---|
| The stored row | context.OriginalEntity (a dictionary) |
| The columns the caller wrote | context.WrittenFields, context.WasWritten("Field") (always true outside an update) |
| The record a typed handler gets | the written values over the stored row |
WriteBack still writes only the written columns plus whatever the handler
changed, so a stored value the handler did not touch is not written again.
A caller that clears a field (writes Guid.Empty) is now told apart from a
caller that did not send it. A re-read cannot tell them apart.
An untyped (EventHandlerBase) handler still gets the written columns in
context.Entity. The test is PartialUpdateHandlerTests.
What follows for the author of a model#
- Stamping header fields in before-save is allowed — it no longer locks the document.
- If you write a header field around the manager (
UpdateDocumentAsyncaddressed straight from before-post), remember: the caller's object will not learn of it. On a subtype withoutIsReadOnlythat is harmless; on a read-only one it is the same trap. - A field the transactional script is obliged to see lives on the DOCUMENT (an extension of somebody else's dictionary does not make it into the generated document class) and is stamped on save, not only at the moment of the transition.
Verified#
SubtypeGuardsTest in the workspace (Purchasing/Tests) covers
LockedSubtypeStillAllowsTransitionOut: Draft → Ordered → Received → Ordered.
The field that locks a purchase order in Received when it is not echoed is
DueDate (stamped from the payment term). ID also diverges after the re-read,
but it is not part of the header. Search for SyncServerAssignedHeader.