Skip to content

Merge rules

Servers and clients merge with the same rules. Because the rules only compare the incoming version with the stored one, the result does not depend on the order in which versions arrive: every node that has seen the same set of versions holds the same data.

When a node (server or client) receives a change for the record (entity, id), it compares it with the version it stores. Ids are compared case-insensitively, stamps byte-wise. The reference server converts stamps to upper case before it compares and stores them; the reference app compares them as received, which gives the same result because writers use upper case.

Stored Incoming Mutable entity movement
nothing upsert or delete store incoming store incoming (a delete is rejected first, see below)
any version stamp greater than stored store incoming keep stored
any version stamp equal or smaller keep stored keep stored

In words:

  1. Mutable records: last writer wins. The version with the greater hlc wins, as a whole. A tombstone is a version like any other: a newer tombstone deletes the record, a newer upsert brings a deleted record back, an older upsert is ignored.
  2. Movements: first write wins. A movement is never changed or deleted after it was written. Once a node holds a movement, every further version with the same id is ignored, whatever its stamp. A wrong booking is corrected by a counter-booking, a new movement in the other direction.
  3. Unknown entity types follow rule 1 and are stored and relayed unchanged.

"Store incoming" on a server also means: append the change to the change log, so other clients pull it. An ignored change does not enter the log.

The machine-readable cases are in test-vectors/merge.json.

A server applies these checks to each pushed change before the merge rules. A change that fails one is rejected and nothing is stored:

Order Check Reason
1 The change is an object with valid entity, id, op, hlc; data is an object for upsert and null or absent for delete invalid_change
2 Not a delete of a movement immutable
3 For an upsert of a known entity type, data passes the payload rules invalid_payload
4 For a document_template, the stored version is not locked (locked: true) locked

The payload rules of the reference server are listed per entity under "Server validation". They check required fields, types and closed value sets of the fields the server reads, and never reject a change because of what it means: a server MUST NOT reject a movement because the resulting stock would be negative, or a record because another record with the same item number exists. Those bookings and records already exist on a device that worked offline.

An organisation can lock a document template on its server (locked: true in the stored payload). From then on the server rejects every pushed change to that record with reason locked, including changes that would set locked to false. Only the server itself can change or unlock the template, with a server-side write. The reference server applies the lock to any document_template record whose stored version has locked: true; the reference app uses it for the loan template and then shows the template read-only.

Because records are replaced as a whole, concurrent edits to different fields of the same record do not combine: the later version wins completely.

Example: two devices edit one article offline.

Device Change Stamp
A sets min_stock to 5 001791792000123-0000-3F2A9C1B
B sets category to "Network" 001791792000456-0000-9D04E6A2

After both have synced, every node holds B's version: category is "Network", and min_stock is whatever B's version had, because B never saw A's change. The protocol accepts this loss; master data is edited rarely and by few people. A client that needs field-level merging would have to introduce its own entity type.

Example: edit against delete. Device A deletes a recipient (tombstone at ...060000-0002-...), device B, offline, edits it at ...050000-0000-.... The tombstone is newer and wins; B's edit is ignored on the server and replaced on B with the next pull. Had B edited later than the delete, the recipient would come back with B's data.

Example: stock. Two devices book receipts of the same article offline. Each receipt is its own movement with its own id, so both are stored and the stock is the sum of both. Nothing is merged, and nothing can conflict. This is why stock is not a field of the article.

The client keeps an outbox: one entry per local write, in the order of writing, holding the complete change. A record that is changed three times before the next sync has three entries; all three are pushed in order, and the last wins.

Result Client action
applied Remove the entry.
ignored Remove the entry. The server has the same or a newer version, or the movement already exists; the next pull brings the winning version if it differs.
rejected Keep the entry, mark it rejected with the message (or the reason when there is no message), and stop sending it. The local version stays in place.

The reference app shows rejected entries with their messages and lets the user either retry them (unmark, so they are pushed again on the next round, useful after the cause was fixed on the server) or discard them (delete the entries). Discarding does not restore the previous version locally: the local record keeps the rejected content until a newer version arrives from the server. To get back in line with the server, a client can disconnect and connect again, which pulls the complete current state.

Results are matched to changes by position: results[i] belongs to changes[i]. If a server answers with fewer results than changes, the reference app processes the results it got and stops pushing for this round.

Until a change is acknowledged, the record is pending. The reference app marks pending movements, delivery notes and loans in its lists (only while a sync method is configured), so users can see what has not reached the server yet.

A client merges every pulled change with the rules above. Changes it pushed itself come back in the pull; their stamps equal the stored ones and they are ignored. The order of pages and of changes within a page does not affect the result, but clients SHOULD apply a page in order and store the cursor only after the whole page was merged (see Pull).

A pulled version may replace a local version that is still in the outbox. That is correct: the pulled version has a greater stamp, so the server will answer the outbox entry with ignored.