Skip to content

Revisions — writing against what was read

A caller reads a sales order, a person edits it for five minutes, and the save goes out. Meanwhile a colleague changed the same order. Without a precondition, the save overwrites every field it touches with values decided against a state that no longer exists.

A revision names the state a caller read. A write that sends it back is refused if the record has moved on since.

A record’s revision is a hash of its input fields — the values a person or an integration writes. System fields are not part of it:

  • An input field changes its value → the revision changes, whoever changed it. A determination that rewrites an input field moves it too.
  • Only system fields change → the revision stays. A rollup adding a picked quantity to an open sales order, an action moving a status, a snapshot address refreshed by the system: none of these make an open form stale.
  • Row order is not part of it; the rows of an input collection count by id and by their input fields. A system collection or struct counts not at all.

The revision is computed, never stored. The same function, revisionOf(schema, record) from @onerp/meta/records, runs on the server and in the browser, so a client holding a record and its schema knows the record’s revision without asking.

A caller deciding on a system fact — reducing a quantity because nothing was picked yet — is not protected by the revision. That rule belongs on the document, as a validator or an action’s precondition.

Every response that carries one record — read, create, update, archive, restore, delete — answers with

ETag: W/"3f1c9a0b7d2e4c65"
Cache-Control: no-store

The tag is weak: two responses with the same revision may still differ in system fields, so no cache may answer a later read with it. Lists carry no tag.

If-Match is honoured on every write to one record: PATCH, DELETE, archive, restore, and a bound action’s POST.

  • No header → no check, exactly as before.
  • * → no check.
  • A list of tags → the write proceeds if any of them is the current revision.
  • Otherwise → 412 core.stale_revision, and nothing is written.

The check runs against the record as the unit of work loaded it. A write racing in between the check and the commit is caught by the internal row version, which answers 409 core.concurrent_modification.

@onerp/client takes the revision as a trailing revision argument on update, delete, archive, restore and operations.run, and sends it as If-Match. The form computes it from the record it loaded and sends it with every save and every action. A refused save keeps the edits and shows the conflict; the person reloads to see what changed.

  • Drafts carry no check: the draft routes ignore If-Match. A draft is one person’s unfinished document.
  • A response replayed for a repeated idempotency key carries no tag; the replay is the stored body alone.
  • The MCP tools do not take or return a revision yet.