ZuloOne
English
Sign in
Menu

🔒 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:

  1. DocumentManager.SaveDocumentAsync narrows 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.
  2. DataService.EnsureDocumentWritableAsync lets a write through on a subtype with IsReadOnly only when every key is in TransitionColumns — Subtype and StatusValue. This allows the idiom doc.Subtype = "…"; SaveDocumentAsync(doc).
  3. A handler may stamp a header field — DueDate out 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 (UpdateDocumentAsync addressed straight from before-post), remember: the caller's object will not learn of it. On a subtype without IsReadOnly that 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.

Where next#

Was this page helpful?