๐Ÿ 

updating graphs

field=, field_=, and the magic this + โ€ฆ.

the big idea: describe the desired state

Updating a Zef graph isn't imperative. You don't "read, modify, write." You declare what a node should look like, and the engine computes the diff.

mental model โ€” updates are data

An update is a list of entity declarations. Each declaration includes an identity (UID or index) that tells Zef which node to update.

Graph([ ET.Person('๐Ÿƒ-abc...', name='Bob'), โ† update node ๐Ÿƒ-abc ET.Person('๐Ÿƒ-def...', age=31), โ† update node ๐Ÿƒ-def ET.Dog(), โ† create a brand-new Dog ])

No specific mutation API โ€” you just describe what you want.

the three write idioms

1. single-value fields (assert exactly-one)

# Replace whatever name is on this Person with 'Bob'
ET.Person('๐Ÿƒ-abc...', name='Bob')

Any existing name edge gets removed; a new one pointing to "Bob" is created. Enforces cardinality of 1.

2. set-valued fields (replace entire set)

ET.Person('๐Ÿƒ-abc...', likes_={'๐Ÿ”', '๐Ÿบ'})

The person's entire likes set is now exactly {'๐Ÿ”', '๐Ÿบ'}. Any previous likes not in the new set are removed.

3. differential updates (the this + trick)

ET.Person('๐Ÿƒ-abc...', likes_=this + {'๐Ÿง€'})        # add
ET.Person('๐Ÿƒ-abc...', likes_=this - {'๐Ÿ”'})        # remove

The this symbol stands for "the current value of this field." But it's not actually read at write time โ€” it's a symbolic instruction that the engine turns into a diff.

why symbolic?

Because reading then writing has a race condition window. When you say likes_=this + {'๐Ÿง€'}, you're handing Zef an intent ("append"), not a value ("replace with this new list"). The engine can apply it atomically at the right moment.

side by side

you writemeaningbeforeafter
name='Bob'set singlename='Alice'name='Bob'
likes_={'a','b'}replace setlikes={'x','y'}likes={'a','b'}
likes_=this + {'c'}appendlikes={'a','b'}likes={'a','b','c'}
likes_=this - {'a'}removelikes={'a','b','c'}likes={'b','c'}
likes_={}clearlikes={'a','b'}likes={}

where these go: the Graph update list

Updates live inside a Graph([...]) declaration, just like creations. Zef figures out from the identity whether something exists already:

# Initial state
g1 = Graph([
    ET.Person('๐Ÿƒ-abc...', name='Alice', likes_={'โ˜•', '๐ŸŒฟ'}),
])
g1.add_to_graph_store()

# Update โ€” add ๐Ÿบ, remove ๐ŸŒฟ, rename Alice to Ali
g2 = Graph([
    ET.Person('๐Ÿƒ-abc...',
        name='Ali',
        likes_=(this + {'๐Ÿบ'}) - {'๐ŸŒฟ'},
    ),
])
g2.add_to_graph_store()

creating versus updating

The same syntax creates or updates depending on identity:

ET.Person(name='Bob')                              # CREATE โ€” no identity given
ET.Person('๐Ÿƒ-abc...', name='Bob')                 # UPDATE if exists, CREATE if not
ET.Person(7, name='Bob')                           # UPDATE the 7th Person node

nested updates create + wire

Nesting works the same in updates as in creates. Each declaration stands on its own:

Graph([
    ET.Person('๐Ÿƒ-abc...',
        lives_in=ET.City('๐Ÿƒ-xyz...', population=4_000_000),
    ),
])
# Interpreted as:
# - update Person(๐Ÿƒ-abc) to point 'lives_in' at City(๐Ÿƒ-xyz)
# - update City(๐Ÿƒ-xyz) to have population=4000000
# - if either node doesn't exist, create it

identifying by ordinal index

Handy for scripting:

# Assume the graph already has 3 Cats
Graph([
    ET.Cat(1, name='Whiskers'),     # updates the 1st Cat
    ET.Cat(2, name='Fluffy'),       # updates the 2nd Cat
    ET.Cat(4, name='Felix'),        # creates the 4th Cat
])

ordinal vs UID โ€” pick one per environment

Ordinals are great for fixtures, tests, and script loads โ€” deterministic and typo-proof. But they're graph-local โ€” don't use them to refer to "the same logical entity" across different graphs. For cross-graph stability, use UIDs.

deleting

To delete, clear the field to {} or tombstone the entire entity:

# Clear a field
ET.Person('๐Ÿƒ-abc...', likes_={})

# Tombstone the entity
ET.Person('๐Ÿƒ-abc...', _deleted=True)     # (syntax may evolve)

cardinality conflicts caught early

Graph([
    ET.Person('๐Ÿƒ-abc...', name='Alice'),
    ET.Person('๐Ÿƒ-abc...', name='Bob'),    # same UID, different name!
])
# โŒ Error: conflicting value for single field 'name'

The "unification" stage notices that you've asserted two different values for a single-valued field on the same entity and errors out.

worked example โ€” a small evolution

# DAY 1 โ€” create
Graph([
    ET.Post('๐Ÿƒ-p1',
        title='hello world',
        author=ET.User('๐Ÿƒ-u1', name='Alice'),
        tag_={'intro'},
    ),
]).add_to_graph_store()

# DAY 2 โ€” edit title and add a tag
Graph([
    ET.Post('๐Ÿƒ-p1',
        title='Hello, World! (v2)',
        tag_=this + {'zef'},
    ),
]).add_to_graph_store()

# DAY 3 โ€” co-author added
Graph([
    ET.Post('๐Ÿƒ-p1',
        co_author=ET.User('๐Ÿƒ-u2', name='Bob'),  # a brand-new field!
    ),
]).add_to_graph_store()

Notice: the co_author field appears for the first time on day 3. No schema migration. The graph shape just evolved.

the whole point

Updates are declarations, not commands. You write what the node should be, Zef makes it so. Differential updates with this +/- let you append/remove without racy reads. Your entity model can grow organically without a single migration.

practice

Start with an Alice who likes coffee. Write three updates:

  1. Add tea to her likes
  2. Set her role to "admin"
  3. Clear her likes
solution
UID = '๐Ÿƒ-ali0000000000000alice'

# initial
Graph([ET.Person(UID, name='Alice', likes_={'โ˜•'})]).add_to_graph_store()

# 1. add tea
Graph([ET.Person(UID, likes_=this + {'๐Ÿต'})]).add_to_graph_store()

# 2. set role
Graph([ET.Person(UID, role='admin')]).add_to_graph_store()

# 3. clear likes
Graph([ET.Person(UID, likes_={})]).add_to_graph_store()

Next up: crawl โ€” the clean way to turn a graph into a tree. โ†’