Skip to content

@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 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.

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

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;
}
}

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 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.

  • A related node isn’t checked: save writes it whole. update writes only what the change altered.
  • An object with no stamp isn’t checked, nor is a fragment in a saveAll.
  • One relationship token covers every relationship: a replace of 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.