Saving changes
Call saveChanges() on the EntityManager to send pending changes to the server.
try {
const saveResult = await em.saveChanges();
console.log(`${saveResult.entities.length} entities saved`);
} catch (e: any) {
console.error('Save failed', e.message);
}You do not need to check em.hasChanges() first. If there is nothing to save, saveChanges() makes no request and resolves with an empty result. Check it anyway if you want to tell the user there was nothing to save.
What happens in a save
- The manager collects every entity in the cache that is
Added,ModifiedorDeleted. - It clears any server validation errors left on those entities from an earlier save.
- If
validationOptions.validateOnSaveis true (the default), it validates each entity that is notDeleted. If any entity has errors, the save is rejected before anything is sent. See Validation. - It sends all the changes in one POST to
<serviceName>/SaveChanges. A Breeze server saves them in one transaction. - When the response arrives, it updates the cache.
saveChanges returns a promise. The cache stays usable while the request is in flight.
After the save
If the save fails, the cache is left as it was. Entities with pending changes keep them, and you can fix the problem and save again.
If the save succeeds, the server returns the saved entities, which may include values set on the server. Breeze merges them into the cache, overwriting the client's values. Then:
- added and modified entities become
Unchanged, with no original values; - deleted entities are removed from the cache and become
Detached; - entities listed in the response's
deletedKeys(deleted by server logic, not by the client) are detached too.
The resolved SaveResult has:
| Property | |
|---|---|
entities | the saved entities, including the ones that were deleted and are now detached |
keyMappings | temporary-to-permanent key mappings — see below |
deletedKeys | keys of entities the server deleted on its own |
httpResponse | the raw response |
Key fixup
A new entity with a store-generated key gets a temporary key when you create it, typically a negative number. The database assigns the real key during the save. The server reports each change as a KeyMapping (entityTypeName, tempValue, realValue), and Breeze updates the entity's key and every foreign key in the cache that pointed at the temporary value.
If you save a new Order with two new OrderDetails, the details' orderID values change from the temporary -1 to the new order's real ID. You do not have to do anything.
Saving selected entities
Pass an array to save only those entities:
await em.saveChanges([order, ...order.orderDetails]);The entities must belong to this manager. Each one is sent as it is, whatever its state, except detached entities, which are skipped.
Be careful with this. It is easy to save a new OrderDetail without its new parent Order, or leave out an entity the server needs. Saving everything is usually the safer default.
A navigation property leaves out entities that have been deleted, so [order, ...order.orderDetails] misses a detail the user has just deleted, and that deletion is not saved. To save an entity together with everything related to it, deletions included, build the list with getEntityGraph from the entity graphs extension:
const graph = (em as HasEntityGraph).getEntityGraph(order, 'orderDetails');
await em.saveChanges(graph); // the order, its details, and the details that were deletedSave errors
This section covers errors specific to saving. For the shape of a Breeze error in general, telling a network failure from a rejected request, and handling errors in your own fetch, see Error handling.
A failed save rejects with an Error. When the failure concerns particular entities, the error has an entityErrors array of EntityError objects:
| Property | |
|---|---|
entity | the entity concerned, if Breeze could find it in the cache |
errorName | the validator or server error name |
errorMessage | the message |
propertyName | the property concerned, if any |
isServerError | false for client validation, true for errors from the server |
try {
await em.saveChanges();
} catch (e: any) {
if (e.entityErrors) {
for (const err of e.entityErrors) {
console.warn(`${err.propertyName ?? '(entity)'}: ${err.errorMessage}`);
}
} else {
console.error(e.message); // network failure, concurrency violation, server exception
}
}Both kinds of error are also added to the entity's own validation errors, so a form bound to entity.entityAspect.getValidationErrors() shows them without extra code. Breeze removes server errors from an entity automatically at the start of its next save.
On the server, you report entity-level failures by throwing EntityErrorsException from a save interceptor. With the Breeze ASP.NET Core server:
protected override Dictionary<Type, List<EntityInfo>> BeforeSaveEntities(
Dictionary<Type, List<EntityInfo>> saveMap) {
if (saveMap.TryGetValue(typeof(Order), out var orderInfos)) {
var errors = orderInfos
.Where(oi => ((Order)oi.Entity).Freight > 1000)
.Select(oi => new EFEntityError(oi, "FreightLimit", "Freight cannot exceed 1000", "Freight"))
.ToList();
if (errors.Any()) throw new EntityErrorsException("Order validation failed", errors);
}
return base.BeforeSaveEntities(saveMap);
}Breeze converts the server's property names with the naming convention, so "Freight" arrives as propertyName: 'freight'. The response status defaults to 403 Forbidden.
The shape of a server error response
The Breeze ASP.NET Core server returns an RFC 9457 problem details document, Content-Type: application/problem+json:
{
"type": "https://breeze.github.io/problems/entity-errors",
"title": "Forbidden",
"status": 403,
"detail": "Order validation failed",
"Code": 403,
"Message": "Order validation failed",
"EntityErrors": [ { "ErrorName": "FreightLimit", "EntityTypeName": "Northwind.Models.Order", … } ]
}The first four members are the standard ones, and they are what to read. The capitalised members below them are what Breeze sent before 3.0, kept so that an application on an older client reads the error unchanged — RFC 9457 §3.2 permits extension members and requires consumers to ignore ones they do not recognise, so the document is conformant either way.
Breeze understands all of it for you: e.message comes from detail, falling back to title, and entity errors are read from either spelling. You only need this if you are writing your own client, or reading the response in a browser's network tab.
Two server settings control it:
BreezeConfig | default | |
|---|---|---|
IncludeStackTraceInErrors | false | a stack trace names source files, line numbers and the build machine's directory layout — turn it on for development only |
IncludeLegacyErrorMembers | true | set false once every client reads the RFC 9457 members |
SaveOptions
A SaveOptions instance controls how a save is made.
| Option | Default | |
|---|---|---|
resourceName | 'SaveChanges' | the server endpoint to POST to |
dataService | the manager's | send the save to a different service |
allowConcurrentSaves | false | whether an entity may be saved while an earlier save of it is still in flight |
tag | none | any value; sent to the server as saveOptions.tag |
Pass options to one save:
import { SaveOptions } from 'breeze-client';
await em.saveChanges(null, new SaveOptions({ tag: 'approve' }));Or set them on the manager as the default for every save:
em.setProperties({ saveOptions: new SaveOptions({ allowConcurrentSaves: true }) });using returns a modified copy, which is handy for starting from the manager's options:
const so = em.saveOptions.using({ resourceName: 'SaveWithAudit' });Named saves
By default every save goes to the SaveChanges endpoint. Sometimes a set of changes is really a command with its own server-side workflow: approve an order, close a period. Rather than route everything through one endpoint and dispatch on the server, you can POST to an endpoint for that command. This is a named save.
const so = new SaveOptions({ resourceName: 'SaveWithComment' });
await em.saveChanges(null, so); // all pending changes
await em.saveChanges(selectedEntities, so); // or a chosen setThe request body is the same change-set that SaveChanges would receive, so the server method has the same signature. In ASP.NET Core:
[Route("breeze/[controller]/[action]")]
public class NorthwindIBModelController : Controller {
private NorthwindPersistenceManager PersistenceManager;
[HttpPost]
public Task<SaveResult> SaveChanges([FromBody] JObject saveBundle) {
return PersistenceManager.SaveChangesAsync(saveBundle);
}
[HttpPost]
public Task<SaveResult> SaveWithComment([FromBody] JObject saveBundle) {
PersistenceManager.BeforeSaveEntitiesDelegate = AddComment;
return PersistenceManager.SaveChangesAsync(saveBundle);
}
}Set SaveOptions.dataService as well if the endpoint is on a different service.
Sending extra information with tag
tag is a free-form value delivered to the server with the save. The server reads it from SaveOptions.Tag:
await em.saveChanges(null, new SaveOptions({ tag: 'addProdOnServer' }));protected override bool BeforeSaveEntity(EntityInfo entityInfo) {
if ((string)SaveOptions.Tag == "addProdOnServer") {
// ...
}
return base.BeforeSaveEntity(entityInfo);
}Optimistic concurrency
A data property whose metadata has a concurrencyMode other than "None" is a concurrency property, such as a RowVersion column. When you save a modified entity, Breeze updates its concurrency property first, unless you already changed it. Numeric values are incremented, and GUID and date-time values get new values. Binary values are assumed to be database-generated rowversions and left alone.
The server compares the original value with the database. If someone else saved the row in the meantime, the save fails. Against the ASP.NET Core server the error message mentions an optimistic concurrency failure. The entity keeps its pending changes, so you can re-query it and try again.
Changes during a save
While a save is in flight, the entities in it have entityAspect.isBeingSaved set to true.
- Saving again. By default, a second
saveChangesthat includes an entity from an in-flight save is rejected withConcurrent saves not allowed - SaveOptions.allowConcurrentSaves is false. Saves of other entities go ahead. SettingallowConcurrentSaveslifts the check. Do that only if you understand the consequences: the second save may send stale original values or temporary keys. - Editing. You can change an entity that is being saved, but when the save completes the server's values overwrite your edit — silently. The entity ends up
UnchangedandhasChanges()isfalse, so nothing tells you a keystroke was lost. Changes to entities that were not part of the save are untouched. Save queuing is what keeps a mid-flight edit. - Rejecting, deleting, clearing.
rejectChanges()on an entity being saved throws, and so doesem.clear(). Deleting or detaching a new entity that is waiting for its server-generated key also throws. Each error says the entity is "in the process of being saved".
The usual answer is to disable the save button, and edits if necessary, until the save returns. An application that saves as the user types can turn on save queuing instead: it holds the second save back rather than rejecting it, and keeps the edit made while the first was out.
Save queuing
If your application saves after every edit, a user can easily make a second change before the first save returns — and that change is overwritten when it does. Save queuing, an optional extension, holds the second save back until the first returns and keeps the edits made in between:
import { enableSaveQueuing } from 'breeze-client/mixin-save-queuing';
enableSaveQueuing(em, true);How it works, what it cannot do, and how a failed queued save is reported: Save queuing, with the other optional extensions.
What goes over the wire
You only need this if you are writing a server or a custom data service adapter.
The request body lists the entities as a flat array. Navigation properties are not included; relationships are carried by foreign keys. Each entity has an entityAspect describing it:
{
"entities": [
{
"OrderID": -1,
"CustomerID": "785efa04-cbf2-4dd7-a7de-083ee17b6ad2",
"EmployeeID": 1,
"Freight": null,
"RowVersion": 0,
"entityAspect": {
"entityTypeName": "Order:#Models.NorthwindIB.CF",
"defaultResourceName": "Orders",
"entityState": "Added",
"originalValuesMap": {},
"autoGeneratedKey": { "propertyName": "OrderID", "autoGeneratedKeyType": "Identity" }
}
},
{
"OrderID": -1,
"ProductID": 1,
"Quantity": 5,
"entityAspect": {
"entityTypeName": "OrderDetail:#Models.NorthwindIB.CF",
"defaultResourceName": "OrderDetails",
"entityState": "Added",
"originalValuesMap": {}
}
}
],
"saveOptions": { "tag": null }
}- Property names are in server form. The naming convention has already been applied.
entityStatetells the server whether to insert, update or delete.originalValuesMapholds the original value of each changed property, so the server knows which columns to update.autoGeneratedKeyis present when the key is store-generated. TheOrderIDof-1is a temporary key.saveOptionscarries only thetag.
The response has the saved entities, key mappings, keys deleted on the server, and errors:
{
"Entities": [
{ "$id": "1", "$type": "Models.NorthwindIB.CF.Order, Model_NorthwindIB_CF.EFCore",
"OrderID": 11078, "CustomerID": "785efa04-cbf2-4dd7-a7de-083ee17b6ad2", "RowVersion": 0 },
{ "$id": "2", "$type": "Models.NorthwindIB.CF.OrderDetail, Model_NorthwindIB_CF.EFCore",
"OrderID": 11078, "ProductID": 1, "Quantity": 5 }
],
"KeyMappings": [
{ "EntityTypeName": "Models.NorthwindIB.CF.Order", "TempValue": -1, "RealValue": 11078 }
],
"DeletedKeys": [],
"Errors": null
}Entities may be returned as a graph with $id/$ref references, like a query response. Breeze reconnects them by foreign key in any case. The client accepts both Entities and entities for the top-level names, and likewise for the others.