Problem
The Stash GraphQL client needs to work against servers at different versions. The upstream Stash project adds new fields, renames types, and deprecates old ones across releases. If the client requests a field the server doesn’t know about, the query fails.| Component | Risk | Example |
|---|---|---|
| Response fragments | Query fails if requesting unknown fields | custom_fields on Scene (appSchema < 79) |
| Input variables | Server rejects unknown input fields | custom_fields in SceneFilterType |
| Type renames | Old type name gone on new servers | PHashDuplicationCriterionInput removed in favor of DuplicationCriterionInput |
Design
Architecture
Detection Query
A single GraphQL request at connect time that combines system status with full schema introspection:__type(name: "...") probes. A single __schema introspection gives the client a complete picture of the server’s schema — no need to add new probes when new capabilities are introduced.
This works with fetch_schema_from_transport=False (required because Stash’s deprecated required arguments violate the GraphQL spec, causing gql’s schema validation to reject the introspection result).
Why Not Full Schema Validation?
Stash deprecates required arguments on mutations (e.g.,Mutation.movieCreate(input:)), which violates the GraphQL spec. The gql library correctly rejects this:
fetch_schema_from_transport=True will not work. Our __schema introspection approach sidesteps this entirely.
Server Version Map
Minimum Supported Version
v0.30.0 (appSchema >= 75). If the detectedappSchema is below 75, the client raises StashVersionError during initialization and refuses to connect. This is a hard requirement — the client is not designed to work with older servers.
appSchema Feature Map
| appSchema | Feature | Stash Release |
|---|---|---|
| 75 | Date precision, tag StashID, multi-URL studios | v0.30.0 (stable) |
| 76 | Studio custom fields | develop |
| 77 | Tag custom fields | develop |
| 78 | Career start/end (replaces career_length) | develop |
| 79 | Scene custom fields | develop |
| 80 | Studio organized flag | develop |
| 81 | Gallery custom fields | develop |
| 82 | Group custom fields | develop |
| 83 | Image custom fields | develop |
| 84 | Folder basename + parent_folders | develop |
What appSchema Tracks vs Doesn’t
appSchema is a database migration counter — it increments when the SQLite schema changes (new tables, columns, indexes). It does not increment for GraphQL-only additions like new mutations, config fields, or filter type renames.
| Detectable via appSchema | Not detectable via appSchema |
|---|---|
| custom_fields (entity storage) | New mutations (destroyFiles, revealFile*) |
| career_start/end (column change) | Config fields (sprite settings, disableCustomizations) |
| Studio organized (column) | Filter type renames (PHash -> Duplication) |
| Folder basename (column + index) | New scan options (imagePhashes) |
| (resolver-only additions) | File reverse resolvers (VideoFile.scenes, #6938) |
__schema introspection. Lookup methods on ServerCapabilities (has_mutation(), has_query(), has_type(), type_has_field(), input_has_field()) provide dynamic queries against the parsed schema data.
Some capability properties are therefore introspection-gated rather than appSchema-derived — has_folder_sub_folders, has_scraped_tag_parent, and has_file_reverse_relationships each check type_has_field(...). These features add GraphQL resolvers over existing data (no migration), so appSchema never moves for them; only introspection can detect them.
Components
ServerCapabilities (frozen dataclass)
__schema data is stored as immutable collections. Lookup methods check against these sets. appSchema-derived properties remain for features tied to database migrations. Frozen to prevent mutation after detection.
FragmentStore (singleton)
Holds all query/mutation strings as instance attributes. Initialized with base (minimum-version-safe) fields at import time, then rebuilt when capabilities are detected. Also stores a reference to _capabilities for use by StashInput.to_graphql().
Gated Field Constants
Each entity has a_BASE_*_FIELDS constant (unconditionally safe) and conditional additions:
| Entity | Base Constant | Conditional Fields |
|---|---|---|
| Scene | _BASE_SCENE_FIELDS | custom_fields (>= 79) |
| Performer | _BASE_PERFORMER_FIELDS | career_start, career_end (>= 78) |
| Studio | _BASE_STUDIO_FIELDS | custom_fields (>= 76), organized (>= 80) |
| Tag | _BASE_TAG_FIELDS | custom_fields (>= 77) |
| Gallery | _BASE_GALLERY_FIELDS | custom_fields (>= 81) |
| Image | _BASE_IMAGE_FIELDS | custom_fields (>= 83) |
| Group | _BASE_GROUP_FIELDS | custom_fields (>= 82) |
| Folder | _BASE_FOLDER_FIELDS | basename, parent_folders (>= 84) |
Input Capability Gating (__safe_to_eat__)
Problem
The upstream Stash schema evolves faster than client releases. When the client sends unknown fields in GraphQL input types, the server rejects the request with a validation error. The UNSET system handles this for optional fields users don’t set, but doesn’t protect against fields the client always includes (e.g., new fields with default values that older servers don’t recognize).Solution
StashInput subclasses can declare a __safe_to_eat__ class variable listing GraphQL field names (as they appear in the schema) that are known to be absent on older server versions:
How It Works
Whento_graphql() is called, after building the aliased dict (field names matching the GraphQL schema), it checks every key against the server schema (via fragment_store._capabilities):
- Type unknown to server → skip gating entirely (don’t blow up on unrecognized types)
- Field supported → pass through normally
- Field unsupported + in
__safe_to_eat__→ silently strip with awarnings.warn() - Field unsupported + NOT in
__safe_to_eat__→ raiseValueError
Zero Call-Site Changes
fragment_store already holds _capabilities after rebuild(). All existing to_graphql() calls remain parameterless — gating is applied transparently.
Pydantic Type Strategy
Superset Models
Pydantic models contain all fields for all supported versions. Fields default toUNSET. When the server doesn’t return a field (because the fragment didn’t request it), it stays UNSET.
Input Safety
The three-level UNSET system handles inputs automatically:UNSET-> field excluded fromto_graphql()-> never sent to serverNone-> field sent asnull- Value -> field sent with value
__safe_to_eat__ provides graceful degradation.
Type Rename Handling
Policy: Only track current and future renames via capabilities. Past renames (movie -> group) are already handled in the codebase with wrapper types and are not retrofitted. ForPHashDuplicationCriterionInput -> DuplicationCriterionInput:
Client Integration
Init Flow
_raw_execute()
A private method on StashClientBase that bypasses the _ensure_initialized() check, used only during capability detection (the session is established but _initialized hasn’t been set yet in the override flow):
Error Handling
StashVersionError
Raised during detect_capabilities() when appSchema < MIN_SUPPORTED_APP_SCHEMA (75):
StashClient.initialize(), preventing the client from operating against an incompatible server. The error message includes both the detected version/appSchema and the minimum required.
Testing
- Unit tests for
ServerCapabilitieslookup methods and property derivation - Unit tests for
__safe_to_eat__gating (strip, pass-through, ValueError, no-gating-when-None) - Unit tests for
FragmentStorewith different capability levels (appSchema 75 vs 84) - Integration tests cross-validating lookup methods against live server introspection
- Regression on existing mixin tests (unchanged behavior for minimum-version servers)