sceneUpdate / imageUpdate / galleryUpdate mutations. They require dedicated mutations — sceneSaveActivity, sceneAddO, imageIncrementO, setGalleryCover, and so on. A “side mutation” is this library’s automation for firing those extra mutations on save() so users don’t have to call them manually.
The Problem
Consider updating a scene’s play history and rating at the same time:save().
How It Works
Each entity class declares a__side_mutations__ dict mapping field names to async handlers:
async function that takes the client and the entity:
save() runs it:
- Runs the main update mutation for all ordinary dirty fields.
- Looks at which side-mutation fields changed.
- Deduplicates handlers — if both
resume_timeandplay_durationare dirty,_save_activityruns only once (handlers keyed by identity). - Fires each unique handler in field-declaration order.
- Handlers typically read the entity’s current + snapshot state, compute the needed mutation(s), fire them, and update local fields from the response.
__side_mutations__ are automatically excluded from the main update input so they don’t get sent twice.
Built-In Handlers
The
Tag handlers are produced by the factory StashObject._make_bulk_relationship_handler(field, client_method, id_param) — covered below.
Diff-Based Handlers
History fields (o_history, play_history) use set-diff logic: the handler compares the current list against the snapshot captured when the entity was loaded, determines which timestamps were added and which were removed, and fires sceneAddO or sceneDeleteO accordingly. This lets users treat the list as a normal Python list:
Delta-Based Handlers
Image.o_counter uses delta logic: snapshot-vs-current integer difference, incrementing or decrementing that many times. Setting o_counter = 0 when the snapshot was non-zero fires a single imageResetO instead.
Bulk Relationship Handlers
Tag doesn’t have a scene_ids field on TagUpdateInput — scenes are associated with tags via Scene.tags (or bulkSceneUpdate(tag_ids=...)). The bulk relationship handler factory builds side-mutation handlers that fire the inverse entity’s bulk update:
tag.scenes = [scene1, scene2] and save() runs, the handler calls client.bulk_scene_update({"ids": [scene1.id, scene2.id], "tag_ids": {"ids": [tag.id], "mode": "ADD"}}). This is how relationship fields that the owning-side’s update input doesn’t support still get persisted.
Interaction With save_batch
store.save_batch(entities) executes all main updates in a single GraphQL round-trip. Side-mutation handlers then fire sequentially, per entity, after the batch response returns. Failures on a main mutation skip that entity’s side mutations; failures inside a side-mutation handler are logged but don’t stop other entities’ handlers from running.
See the Batched Mutations guide for the full ordering model.
Queued Side Operations
Occasionally you want to fire an extra mutation onsave() that isn’t tied to a field — for example, a one-shot mark-as-processed. Use _queue_side_op():
Defining Custom Handlers
Subclass an entity and extend its__side_mutations__:
"my_custom_field" in __tracked_fields__ so the dirty detector notices changes to it.
See Also
- Relationship DSL — declaring entity relationships with
belongs_to,habtm,has_many,has_many_through. - Batched Mutations — how side mutations fit into the batch flow.
- Bidirectional Relationships — architectural rationale for inverse sync.