@NodeStamp: refusing a save when the node changed
Strongly recommended on any class that is loaded, changed and saved. It plays the part of JPA’s @Version: optimistic
locking, with a random value in place of a counter.
@NodeFragment({ labels: ['Person'] })export class Person { @NodeId() id: string; name: string; @NodeStamp() stamp?: string;}The field is an optional string on a @NodeFragment, one to a fragment. A stamp is a node’s, so it is refused on a
view and on a relationship fragment.
The Stamp
Section titled “The Stamp”The stamp is two random tokens of 64 bits each, stored on the node under __drivine.stamp as
3fa9c1d27b40e8a6:91d0f4b2c7ee5a13, whatever the field is called. Loading fills the field. Treat it as opaque.
- The data token, the first, speaks for the node’s properties and labels.
- The relationship token, the second, speaks for its relationships, of every type, in both directions.
- To mark a node is to replace one of its tokens. A save marks what it changes, and so must Cypher of your own.
Every node a save writes is given a stamp if it has none, whether or not its class declares a @NodeStamp field.
When a Save Is Refused
Section titled “When a Save Is Refused”A save is refused when what it would overwrite changed since the object was loaded. Every save of an object that
carries a stamp compares the data token. A save with replace compares the relationship token too. A refused save
writes nothing and throws StaleObjectException.
| Since the object was loaded, another writer… | save |
save with replace |
update |
|---|---|---|---|
| changed a property or a label of the root | refused | refused | loads again and re-applies the change |
| added or removed a relationship of the root, or changed one’s properties | applied | refused | applied |
| changed a related node | applied: the related node is written over the change | the same | applied |
| deleted the root | refused, deleted is true |
refused, deleted is true |
returns undefined |
| changed anything with Cypher that doesn’t mark it | not noticed | not noticed | not noticed |
An object whose stamp is undefined isn’t checked, unless it is saved with from: it makes the node or overwrites it.
The save is one statement that takes the node’s write lock before it compares, so of several writers holding the same
stamp exactly one succeeds.
On Neo4j a load can be handed a stamp newer than its data. Neo4j reads under read committed, without a snapshot, so
a load that runs while another writer commits can find that writer’s stamp and the data from before its write. update
therefore compares each field of the root as it loaded it too, under the node’s lock, and loads again if one differs: a
field’s value (a @JsonPacked field’s as the text the node held), a @PropertyBag’s keys and their values, and the
labels a @NodeLabels field reads. A field the object doesn’t hold and its class doesn’t initialize isn’t compared. A
save checks the same when you give it the object as loaded, as from. A @JsonPacked field of a
from that a load made is compared as the text the node held; of one that no load made, such as a copy, as a save
packs it, so JSON another writer packed with other spacing has the save refused, though nothing changed. Without
from a save has only the stamp to go on, and in that moment it can write over the other writer’s change: hold on to
what you loaded and pass it, or change the node with update. Where only the stamp makes the round trip, as through a
web form, the stamp is all there is. Memgraph reads from a snapshot, FalkorDB runs no read beside a write, and Oracle
and Postgres read a statement whole, so on them the stamp is what was read.
try { await gom.save(edited);} catch (e) { if (e instanceof StaleObjectException && !e.deleted) { // another writer got there first: e.foundStamp is what the node carries now await gom.update(Person, edited.id, (it) => { it.name = edited.name; }); } else { throw e; }}Existing Data
Section titled “Existing Data”A node written before stamps has none, and loads with the field undefined. The first save of it isn’t checked and gives it a stamp. To stamp every node of a label at once:
await pm.execute( new QuerySpecification(`MATCH (n:Person) WHERE n.\`__drivine.stamp\` IS NULL SET ${Stamps.setClause('n')}`));Cypher You Write Yourself
Section titled “Cypher You Write Yourself”Cypher should mark what it changes: a write that doesn’t is not noticed, and a checked save of an object loaded
before it is applied over it. Stamps.setClause marks a node whose properties or labels you change, and
Stamps.linksClause each end of a relationship you add, remove or change:
`MATCH (p:Person {id: $id}) SET p.name = $name, ${Stamps.setClause('p')}`;
`MATCH (a:Person {id: $a}), (b:Person {id: $b}) CREATE (a)-[:KNOWS]->(b) SET ${Stamps.linksClause('a')}, ${Stamps.linksClause('b')}`;The property’s name holds a dot, so Cypher that names it quotes it: n.`__drivine.stamp`. Cypher that replaces
every property (SET n = $props) removes the stamp, and the next checked save of an object loaded before is refused.
What a Stamp Doesn’t Cover
Section titled “What a Stamp Doesn’t Cover”- A related node isn’t checked:
savewrites it whole.updatewrites only what the change altered. - An object with no stamp, saved without
from, isn’t checked, nor is a fragment in asaveAll. - One relationship token covers every relationship: a
replaceof one list is refused when a relationship of another type was added. - Deleting a node removes its relationships without marking the nodes at their other ends.

