Side Mutations¶
Some fields on a Stash entity cannot be written through the normal 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:
scene = await client.find_scene("1")
scene.rating100 = 90 # writable via sceneUpdate
scene.resume_time = 42.0 # NOT writable via sceneUpdate
scene.play_history = [*scene.play_history, datetime.now()] # NOT writable via sceneUpdate
await scene.save(client)
Without side mutations you'd have to call three separate client methods in the right order and track which fields changed. Side mutations let you just set the fields and call save().
How It Works¶
Each entity class declares a __side_mutations__ dict mapping field names to async handlers:
class Scene(StashObject):
__side_mutations__: ClassVar[dict] = {
"resume_time": _save_activity,
"play_duration": _save_activity,
"o_counter": _save_o,
"o_history": _save_o,
"play_count": _save_play,
"play_history": _save_play,
}
A handler is an async function that takes the client and the entity:
When 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.
The fields listed in __side_mutations__ are automatically excluded from the main update input so they don't get sent twice.
Built-In Handlers¶
| Entity | Fields | Mutation(s) Fired |
|---|---|---|
Scene |
resume_time, play_duration |
sceneSaveActivity |
Scene |
o_counter, o_history |
sceneAddO / sceneDeleteO (diff-based) |
Scene |
play_count, play_history |
sceneAddPlay / sceneDeletePlay (diff-based) |
Image |
o_counter |
imageIncrementO / imageDecrementO / imageResetO |
Gallery |
cover |
setGalleryCover / resetGalleryCover |
Tag |
scenes, images, galleries, performers, groups, scene_markers |
bulk{Type}Update with tag_ids=[self.id] |
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:
scene.o_history = [*scene.o_history, datetime.utcnow()] # adds one
scene.o_history = [t for t in scene.o_history if t.year >= 2024] # removes old
await scene.save(client)
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:
# In Tag.__side_mutations__:
"scenes": StashObject._make_bulk_relationship_handler(
"scenes", # Local field on the Tag
"bulk_scene_update", # Client method to call
"tag_ids", # Input field name on BulkSceneUpdateInput
),
When 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 on save() that isn't tied to a field — for example, a one-shot mark-as-processed. Use _queue_side_op():
async def mark_processed(client: StashClient, scene: Scene) -> None:
await client.scene_save_activity(scene.id, resume_time=0.0)
scene._queue_side_op(mark_processed)
await scene.save(client) # runs main update + mark_processed
Queued ops are FIFO and run after the field-based handlers. They're cleared once executed, so re-saving the same entity won't re-fire them.
Defining Custom Handlers¶
Subclass an entity and extend its __side_mutations__:
class MyScene(Scene):
__side_mutations__: ClassVar[dict] = {
**Scene.__side_mutations__,
"my_custom_field": _my_handler,
}
@staticmethod
async def _my_handler(client: StashClient, scene: "MyScene") -> None:
# Fire whatever mutation(s) this field needs
await client.my_custom_mutation(scene.id, scene.my_custom_field)
Be sure to include "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.