Changelog
- Major (X.0.0): Breaking changes or major new surface
- Minor (x.Y.0): New features, backward compatible
- Patch (x.y.Z): Bug fixes and minor improvements
Pre-3.0 history is not included here. See
git logand the historical tags for changes before 3.0.0.
6.2
6.2.1 (2026-9-3)
- fix:
- One operation per request, selected per spec (#143): A document
with multiple operations selecting the same top-level group
cross-contaminated the two selections: on ≤6.1.2 the later
operation's projection tree silently overwrote the earlier one's —
a mutation declaring
{ id }could leak fields only another operation asked for (issue #142); since 6.2.0 the same legal document raisedALIAS_CONFLICTinstead.QueryParser.parse_ operations()now keeps per-operation trees, the entity-first executor executes exactly ONE operation per request (operationNamematches a named operation, a single anonymous operation runs as-is, several without a name errors per spec), and theoperation_namethe handler always accepted (the federation transport always passed it) finally takes effect.compose_queryrequires exactly one operation per document. Single-operation requests — the overwhelmingly common case — are unaffected.
6.2.0 (2026-9-2)
- feat:
-
Method-level GraphQL aliases on queries and mutations (#141): Aliased fields were silently collapsed — the same method invoked N times via aliases executed only once (compose) or executed N times while overwriting each other down to the last response key (entity-first); either way the caller got one result and no signal (issue #140). Both paths now support method-level aliases: each alias is an independent invocation with its own arguments and sub-field projection, and response keys are the aliases. Mutations run serially in declaration order — N aliases means N side effects, with no execution-level dedup. Nested-field aliases (DTO field renames inside a method selection) stay out of scope and now fail loudly instead of mis-projecting.
-
Three-state mutation feedback, operation-scope fail-stop (#141): A failed mutation no longer voids its whole group: succeeded calls keep their results, the failing key becomes
nullwithMUTATION_FAILED(entity-first:RESOLVER_ERROR), and every later mutation in the same operation is skipped and markedSKIPPED_PRIOR_FAILURE— across group/service boundaries, matching GraphQL's operation-level serial semantics. Queries are unaffected by the abort flag and never fail-stop. -
Federation wire never carries aliases (#141): The mounter-side wire renderer emits original field names for aliased nodes — aliases terminate at the mounter, members see zero changes and zero alias burden.
-
behavior change:
-
Duplicate response keys rejected (#141): Alias repeats, an alias colliding with a field name, and plain duplicate fields (
f { x } f { y }) used to silently keep only the last selection. They now raise anALIAS_CONFLICTerror before anything executes; field merging is intentionally not supported. -
Query failures are per-field (#141): A failing query method nulls only its own response key with a
QUERY_FAILEDerror entry (carryingextensions.service_method) — on compose the whole response used to becomedata: null; on entity-first the whole entity group used to be voided. Sibling results now survive on both paths. -
Error paths use response keys (#141):
errors[].pathnow uses response keys (the alias when present), consistent with thedatakeys clients actually received, per the GraphQL spec; messages and server logs keep original field names for schema lookups. -
fix:
- Cancelled compose queries stop instead of returning 200 (#141):
The concurrent query path (
asyncio.gather(..., return_exceptions= True)) downgradedCancelledErrorto aQUERY_FAILEDerror entry — a cancelled request (client disconnect,asyncio.timeout) would keep executing its serial mutations and return a normal response. Cancellation now propagates:Exceptionbecomes a per-field error, any otherBaseExceptionis re-raised.
6.1
6.1.2 (2026-8-20)
- fix:
-
Unknown selection fields error instead of silently dropping (#138): A misspelled field (
{ Team { by_filter { nmae } } }) passed through unvalidated and was dropped at serialization —success: truewith empty-object rows, zero signal for an AI agent to self-correct (found by an MCP blind test). Pre-execution selection validation now appends a GraphQL-style error ("Cannot query field 'nmae' on type 'Team'") and skips only the offending method; sibling methods still execute. Paginated packages ({items, pagination}) are validated through one shared path, so unknown keys name the actual wrapper type ({Entity}{Field}PagePackage/{Target}Result) instead of pointing at the entity type. -
Resolver errors null the entity group, add RESOLVER_ERROR (#138): A failed method used to leave an empty-object
Entity: {}indata— a misleading "empty success" for clients that readdataand ignoreerrors. Method return types are non-null, so the failure now nulls the whole group (GraphQL null propagation at group granularity) and the error entry carriesextensions.code = RESOLVER_ERRORfor machine-side classification. -
page_by_*_inorder falls back to default_order (#139): The synthesized federation batch root renderedorderas optional in SDL, but the function body hard-readkwargs["order"]—KeyErrorfor any SDL-direct caller (e.g. an MCP agent) that omitted it. The mounter always sentorderexplicitly, so federation e2e tests never exercised the omission path. The body now falls back topage_config.default_orderonNone(invalid strings still fail with Unknown-order-profile), and the__signature__Parameter carriesdefault=so introspection sees the real contract. -
breaking change:
- Removed superseded per-operation MCP tool modules (#138):
nexusx.mcp.tools.register_get_operation_schema_tools/register_list_operations_tools/register_graphql_query_tool/register_graphql_mutation_toolare removed. These were internal wiring helpers with no documented usage; the FastMCP servers themselves are unaffected. Usecreate_single_app_mcp_serveror the multi-app builders instead.
6.1.1 (2026-8-14)
- feat:
-
Voyager member clusters & colors for ComposedErManager (#137): A composed manager's entities all landed in a single Python-module cluster on the voyager pages, making multi-engine deployments indistinguishable at a glance. Members that declare
service_namenow cluster under their own name on both the ER diagram and the UseCase page (DTOs registered viadto_classesfollow their member), and the opt-inErManager(color=...)fills the cluster background. Ownership priority is federation service > memberservice_name> Python__module__, so federation stacking stays orthogonal. Apps that declare nothing render byte-identically to before (locked by a golden-file test). -
fix:
- Header text color follows fill luminance (#137): Node headers kept
hardcoded white text, illegible on the pastel cluster fills this feature
recommends.
text_color_for()now picks black or white by relative luminance, and the check runs after the virtual-entity fill override so the light yellow«virtual»header keeps dark text.
6.1.0 (2026-8-13)
- feat:
- CLI
--selectfield projection (#136): Every command generated bycreate_use_case_clinow takes--selectfor GraphQL-like field projection over the returned DTO — trim JSON output to the fields you want (e.g.--select "name task_count", or nested--select "title owner { name }"). Reuses the sameapply_selectionengine as MCPcompose_query, so CLI and MCP share one selection mechanism. A method's--helpnow lists the return DTO's fields (nested relationships markedselectable), so you know what--selectcan pick.parse_selectionaccepts bare field lists without outer braces.
6.0
6.0.0 (2026-8-10)
- breaking change:
- Federation member config orthogonalized to the entity (specs/020, #133): Removed
AutoQueryConfig.batch_keys/batch_pagesandSubsetConfig.federation_join_key. Member federation capability is now declared on the entity:__federation_keys__— which fields are federation batch entry keys; each generates aby_<key>_inroot (WHERE key IN (values)).__pagination_orders__— the single order-profile carrier: a key in__federation_keys__additionally yields apage_by_<key>_inroot, while a local relation name yields a local paginated loader (one carrier, routed by__federation_keys__).- γ DTO (
federation_public=True) join key + order are derived from the source entity's__federation_keys__/__pagination_orders__;SubsetConfig.federation_join_key→federation_key(a selector for multi-key entities, auto for a single key).AutoQueryConfignow holds only toggles (default_limit,generate_by_id, ...).
- Federation pagination auto-detected,
RemoteRelationship.paginationremoved (specs/021, #134): The per-edgeRemoteRelationship(pagination=True/False)parameter is gone. Federation pagination is now auto-detected: the mounter probes the member'spage_by_<join_remote>_inroot (from__pagination_orders__) and wires the paged loader; the query'slimit/orderdrive top-N at runtime.enable_paginationstays local-only (member/mounter symmetric, each its own local relationships). Fixes the "per-edge pagination doesn't propagate" gap (multi-hop pagination no longer breaks when an intermediate edge forgetspagination=True). - Deprecated API cleanup — AppConfig dict + AutoQueryConfig(session_factory):
Removed the legacy
AppConfigdict form forcreate_mcp_serverapps (useApplicationinstances). RemovedAutoQueryConfig(session_factory)positional compat (passsession_factorytoGraphQLHandler/Applicationinstead). -
create_*factory naming unified:create_mcp_server→create_multi_app_mcp_server;create_simple_mcp_server→create_single_app_mcp_server(symmetric multi/single);create_jsonrpc_router→create_use_case_jsonrpc_router(use_caseprefix consistency). -
feat:
- DTO auto-discovery —
federation_publicwithoutdto_classes(#135): A DTO withfederation_public=Trueis now auto-discovered from its source entity (__subset__.kls) — no need to passdto_classes=[DTO]to the handler. The metaclass registers public DTOs on their source entity; the handler discovers them via its entity list. Eliminates the redundant two-step (federation_public + dto_classes).
5.4
5.4.1 (2026-8-8)
- fix:
- Correct stale PyPI metadata and dedupe dev dependencies:
Package metadata still advertised
Development Status :: 3 - Alphawith the v0.x-era description "GraphQL SDL generation and query optimization for SQLModel", contradicting the README which declared semver-stable maturity back at v4.0 — so every PyPI visitor saw an Alpha-tagged, mis-described project. Bumped to5 - Production/Stableand rewrote the description to reflect the current positioning (batch-loaded query graph + multi-protocol methods + "for people and AI"). Also collapsed the duplicatedevdependency definitions:[project.optional-dependencies].dev(PEP 621,pytest>=7) clashed with[dependency-groups].dev(PEP 735,pytest>=9); the PEP 621 one was removed and its two unique packages (pytest-benchmark,greenlet) folded into the dependency-groups dev group.uv sync --all-extras(CI) is unaffected — verified by 1523 tests passing.
5.4.0 (2026-8-8)
- feat:
-
ComposedErManager — compose multiple engines in one process (#132): Combine several self-contained
ErManagers (each on its own engine/session) into a single query proxy. A resolver from the composed manager traverses entities across different databases transparently, with no HTTP bridge — the same-process dual of federation (specs/012). Cross-engine edges are declared on the composition layer (cross_relationships=), so members stay self-contained and usable alone. Ships withGraphQLHandler/Applicationer_manager=injection and aLoaderRegistryProtocol upgraded from anErManageralias to a real contract. -
fix:
- Pagination detection uses explicit intent, not structure sniffing (#132):
Detection keyed off the literal
"items"string, so any ordinary relationship nameditems(Order ── items ── OrderItem) was misread as a paginated package and the parent field was silently dropped —itemswas an undocumented reserved word. Producers now return a typedPaginatedPackage/KeyedPaginatedPackage; the build path readsRelationshipInfo.is_paginated; consumers branch onisinstance. Relationship naming is free again.
5.3
5.3.0 (2026-8-7)
- feat:
-
Use Case voyager federation clusters (#130): The Use Case page now renders federated remote types like the ER diagram — each owning service clusters as a dashed box colored by its declared
RemoteService(color=...). Previously remote DTO types rendered flat (module = pydantic.main) with no service boundary.UseCaseVoyagertakes thefed_registry(fed fromer_manager._fed_registryviaVoyagerContext), tags materialized remote types by service, and reuses the existingRenderercoloring path; the ER diagram path is unchanged and the front-end needs no change (coloring lives in the DOT template). -
fix:
get_return_typere-resolves placeholder DefineSubset (#131): A method return annotation is evaluated atdeftime, so a RemoteRef-sourcedDefineSubsetfroze its placeholder class into the annotation;federatelater replaced the module attribute with the resolved class but the frozen annotation missed it, leaving voyager / REST swagger / JSONRPC with the placeholder (SUBSET_REFERENCE = None) and a broken cross-service subset chain.get_return_typenow re-resolves each class by name against the method's module globals (mirroring federate'ssetattr); router, JSONRPC and the introspector all route through it, so every consumer sees the resolved class with no federation internals leaking into the use_case layer.
5.2
5.2.0 (2026-8-7)
- feat:
-
DTO federation γ path (specs/016): Compose cross-service graphs at the DTO layer. A member marks a
DefineSubsetDTO as federation-public (SubsetConfig(federation_public=True)), exposing it via/nexusx/dto-introspection+/nexusx/dto-batch; a mounter DTO field then references the member public DTO directly (reviews: list[rev_svc.ReviewDTO]), and the Resolver auto-loads the cross-service tree — no gql string, no manual per-row assembly.Paged(...)field defaults drive the member's SQL-level top-N; the caller overrides per-field viaResolver(context=...). Member values are read-only; the mounter adds derived fields via its ownresolve_*/post_*. See the β-vs-γ table indocs/advanced/federation.md. -
DTO-first gql execution (specs/018): The entity-first gql path now shares the UseCase mental model — a DTO schema is built dynamically from the gql selection, then resolved by the Resolver. Activates the previously-dormant
response_builder.build_response_model, extends it to paginated packages + federation materialized types, and moves β federation dispatch into the Resolver (fetch_remote_subtree). The temporaryuse_response_builderflag is removed (new path is the only path); flag-on/off equivalence tested. -
Unified
core_builder(specs/021):build_response_model(entity-first) andbuild_subset_model(UseCase compose) are now thin shells over one recursive builder,core_builder.build_model, parameterized by aFieldResolverProtocol that abstracts the only real difference — where a nested field's type comes from (SQLModel relationships vs pydantic DTO annotations).FieldResolution.nested_shapecollapses the formeris_list/is_optional/nested_annotation_factorytriple into one callable.SelectionErrormoved down tocore_builder(no reverse-import of use_case; re-exported for back-compat). -
Declarative
Pagedfield pagination:Annotated[list[X], Paged(limit=N)]as a DTO field default declaratively drives per-parent top-N slicing at the member batch root (ROW_NUMBER window); the Resolver merges field defaults + caller context per field. Order omitted → member'sdefault_order. -
Auto-derive
federation_join_key: When a federation-public DTO's subset has exactly one foreign-key field,federation_join_keyis auto-derived; zero/multiple FKs still require it explicitly. Fail-fast at class creation. -
Paged provider (specs/019) / drop
PAGED_MARKER(specs/020): The Resolver is decoupled from raw gql paged arguments via an injected provider; the model goes back to pure shape (no marker attribute). -
fix:
- Pagination chain integrity (GAP A/B/E + γ member checks F1–F3): The
paginated federation path now carries the merged
Pagedparams (notPageLoadCommandobjects the remote loader can't unwrap); graphqlUndefined/non-wire values can never reach the wire; an items-only paged selection no longer leaks internal RowMapping columns. Mapped[list[X]]relationshipis_listmisclassification + regression tests.-
Preserve nullable and paged remote-relationship shapes through the builder.
-
refactor:
fetch_dto_subtree→prepare_dto_loader: the γ primitive returns a prepared loader (it does not fetch); rename makes the β/γ asymmetry honest. Addedset_remote_page_paramsso all three remote side-channels now have explicit setters.Directiondemoted out of the top-level__all__(no production touchpoint; still importable fromnexusx.standard_queries). Non-breaking.
5.1
5.1.1 (2026-8-6)
- fix:
- Support enums nested in input models (#126): When an input model field was itself another input model containing an enum, the SDL/introspection enum-collection walk stopped at the outer model and never reached the nested enum — so the generated schema omitted that enum type (or rendered a dangling reference). The walk now recurses through nested input models to collect all reachable enums, and the collection logic is shared between SDL generation and introspection via one helper (
schema_helpers).
5.1.0 (2026-8-2)
- feat:
-
Local pagination order/direction (
specs/015): Locally-paginated relationships (enable_pagination=True, e.g.Review.comments) now support query-timeorder/directionselection, matching federation pagination's flexibility. A member declares named order profiles on the entity class via__pagination_orders__ = {relation_name: BatchPageConfig(default_order, orders={profile: PageOrder([OrderTerm(field, direction, nulls)])})}, and callers querycomments(limit, offset, order: NEWEST, direction: DESC) { items pagination }— choosing a profile and flipping direction per-query, without redeploying. The sort field stays closed (member owns index control); only the direction is opened up, same model as federation's specs/014.The feature reuses federation's sort core rather than reimplementing it:
_resolve_page_ordersvalidates profiles (enum-safe names, single-column, SQL column, direction, nullable nulls, default∈keys) at startup; the localpage_loader(create_page_one_to_many_loader/create_page_many_to_many_loader) builds ORDER BY via_build_order_expressions(_apply_direction(...))instead of the fixedsort_field; SDL/introspection render theorderenum +directionvia the kind-agnosticfederation_order_enum_layout(local pagination rides the same render path asREMOTE_PAGED); and the executor injectsorder/directionfromselection.argumentsinto the loader command. nulls follow direction flips (desc+nulls_last ↔ asc+nulls_first), inherited from_apply_direction.Declaration lives on the entity class because SQLModel's ORM
Relationshipis not extensible —Relationship(page_orders=...)raisesTypeError(verified at implement time, correcting the spec's initial "extend Relationship" wording).__pagination_orders__coexists withorder_by(profile takes precedence;order_byis the fixed fallback when no profile is declared), so every existing locally-paginated relationship keeps its behavior unchanged (zero regression —test_loader_pagination/test_pagination_mixed/test_federation_*all green). Composes with federation pagination:Product.reviews(limit) { items { comments(order, direction) } }exercises outer federation pagination and inner local order/direction independently (built on the 5.0.1 fix that made local pagination traverse the federation items subtree).
5.0
5.0.1 (2026-8-1)
- fix:
- Member-side pagination now traverses the federation items subtree (
specs/013gap): When a member owned a locally-paginated relationship (enable_pagination=True, e.g.Review.comments) and a mounter selected it inside a federation-paginated items subtree (Product.reviews(limit: 5) { items { comments(limit: 2) { ... } } }), the mounter crashed with a pydanticValidationError. It materialized the remotecommentsfield as a flatlist[Comment], but the member returned a{items, pagination}package, somodel_validaterejected the dict. Root cause: ER introspection dropped the member's local-pagination state —RelDescriptor.paginationonly flagged federation remote pagination (RemoteRelationship(pagination=True)with atarget_service), so the mounter never sawcommentswas paginated on the member side and built a flat-list slot. The federation link layer already supported this case end-to-end — the materializer's package slot (registry.py), the renderer'sREMOTE_COALESCED + paginationbranch (pagination_schema.py), and the executor'sREMOTE_COALESCED → skip load(query_executor.py) — only the introspection signal was missing. Fix:serialize_er_introspectionalso marks a relationship paginated when it is a local list with apage_loaderset, so the mounter materializes a package slot, renderscomments(limit, offset), and reads the member-filled package without re-loading. One-condition change; the downstream chain was already in place.
5.0.0 (2026-8-1)
5.0 adds federation: any nexusx service can mount other nexusx services into one unified graph, with no privileged router/gateway role. A client queries the entry service exactly as if it were a monolith; the entry service orchestrates cross-service traversal by issuing one nested GraphQL query per mounted service, so each member still resolves its own composed subgraph with its own N+1-proof batch executor — federation does not reintroduce N+1 at the network boundary. The work landed in three layered specs (specs/012 base federation → specs/013 remote pagination → specs/014 query-time order/direction), plus a three-service demo (demo/federation/: catalog / reviews / users) and a bilingual guide (docs/advanced/federation.md, en + zh). The version bump to 5.0 reflects this major new surface, not a breaking change to existing APIs: monolith behavior (SQLModel GraphQL, Resolver, Voyager) is unchanged when federate is not used, and the in-process federation APIs renamed/removed during 012 → 013 → 014 (e.g. RemoteRelationship.order, the old by_<key>_in_page(sort_field, sort_direction) root) never shipped under any tag, so there is no external migration burden.
- feat:
-
Relative-composition cross-service federation (
specs/012): Mounting is a symmetric capability of every nexusx service — declare aRemoteRelationshipin__relationships__and runawait er.initialize()at startup; the entry service of a query is its orchestrator (per-query, not per-topology), and mounting a service mounts its entire query surface (including that service's own downstream mounts), so transitive reach is inherent. Composition uses ER-graph introspection as the source of truth, not SDL — SDL is a lossy projection that strips FK fields and carries no cardinality/direction, while ER introspection hands overRelationshipInfo(with the join key and cardinality) federation actually needs, staying same-source with Voyager and the executor.from nexusx import RemoteService, RemoteRelationship, ErManager reviews = RemoteService("reviews", url="http://reviews:8021") class Product(SQLModel, table=True): id: int = Field(primary_key=True) name: str __relationships__ = { "reviews": RemoteRelationship( fk="id", target=list[reviews.Review], # to-many; bare reviews.Review = to-one name="reviews", join_remote="product_id", ) } er = ErManager(base=..., session_factory=...) await er.initialize() # transitively pulls reviews' ER fragment, validates, materializesData fetching is done by
RemoteLoader, a DataLoader that turns a batch of join keys + a nested selection into one GraphQL query against the member'sby_<join_remote>_inroot and returns the rows grouped/aligned to the DataLoader's positional contract (missing keys map toNonefor to-one /[]for to-many). Remote types are materialized at init time (not lazily at runtime) as bare-named pydantic classes via a two-passFederatedTypeRegistry(BFS pull of reachable ER fragments with a visited-set, then topologicalcreate_model+model_rebuildagainst a bare-name → class namespace), so the external schema shows only bare type names with no service prefix and multi-hop traversal across services is transparent to the client. The canonical"<srv>.<typename>"identity lives only in the registry for routing/validation/disambiguation.RemoteRelationship.targettakes aRemoteRef(RemoteService("srv").TypeName); a dotted name is not a valid Python identifier, so it is treated as a declaration marker parsed by the framework and never enters Pydantic forward-ref resolution — avoiding fights with forward-ref /model_rebuild/ mypy at every layer.DefineSubsetaccepts aRemoteRefas its source too, andresolve_deferred_subsets(run duringfederate) resolves each to the materialized class. Seven classes of misconfiguration fail-fast at startup: unknown service prefix, missing typename, missing/incompatible join field, missingby_<key>_inroot, duplicate service name, duplicate cross-service bare type name, and a mount-graph cycle. Federation requires the optionalnexusx[federation]extra (httpx); callingfederate()without httpx raises an informativeImportError. -
Member-side offset pagination for remote to-many relationships (
specs/013): A mounted to-many relationship can opt into member-side pagination viaRemoteRelationship(..., pagination=True). The physical ordering is owned by the member (which controls its indexes); the mounter only selects a named semantic order profile. Profiles are declared inAutoQueryConfig.batch_pages→BatchPageConfig(default_order, orders={profile: PageOrder})→PageOrder(terms=[OrderTerm])→OrderTerm(field, direction, nulls), and the member exposes apage_by_<key>_in(keys, limit, offset, order)root that paginates per-parent with a windowROW_NUMBER(). The business client only ever passeslimit/offset;total_countis computed only when selected; primary-key columns not in the profile are appended as a deterministic tie-breaker; a malformed remote response raisesRemoteQueryErrorwhile a legitimately missing key maps to an empty page. ER introspection exposes only the semantic capability (protocol, default order, profile names + descriptions) — never the physical columns, directions, or null ordering. The never-released predecessor rootby_<key>_in_page(sort_field, sort_direction)was deleted outright with no compatibility shim.# member side — declares the page capability AutoQueryConfig(batch_pages={"Review": {"product_id": BatchPageConfig( default_order="NEWEST", orders={ "NEWEST": PageOrder(terms=[OrderTerm(field="created_at", direction="desc")]), "HIGHEST_RATING": PageOrder( terms=[OrderTerm(field="rating", direction="desc", nulls="last")]), }, )}}) # client side # reviews(limit: 5, offset: 0) { items { title } pagination { has_more total_count } } -
Query-time order/direction for federated pagination (
specs/014): The order-profile choice moves from a deploy-time static binding to a query-time caller choice. The mounter renders the member's profile set as a GraphQLorderenum (values = profile names, default = the member'sdefault_order) plus a mounter-owned globaldirectionenum (ASC/DESC) on the paginated relationship field, so a client flips ordering per-query without the member predefining multiple relationships or redeploying. The member'spage_by_<key>_ingains adirectionargument; flipping it overrides each profile term's default direction and nulls follow the flip (desc + nulls_last↔asc + nulls_first), with the inner window and outer query using byte-identical order expressions. Only the direction is opened up — the sort field stays closed (the member still owns index control); a caller cannot pass an arbitrary sort field, only pick a name and flip direction.RemoteRelationship.orderis removed (the sole source of order is now the query argument),federatevalidation is relaxed (pagination=Trueno longer requiresorder; instead it requires the member'spage_capability.ordersto be non-empty), and aPageOrderis constrained to a singleOrderTerm(multi-column profiles are rejected at member startup). -
Voyager renders the full federated graph with ownership tags (
specs/012 US4): After federation init, Voyager / ER draws the complete graph (local + remote materialized entities, bare-named), each remote node tagged by its owning service and cross-service edges rendered correctly; an opt-inRemoteService("reviews", url=..., color=...)tints a member's cluster. -
internal:
- Schema generation is now registry-driven: SDL generation and
__schemaintrospection were switched fromget_type_hints-based field discovery to reading theErManagerregistry, so custom/remote relationship fields (which are not type annotations) and materialized remote types reach the external schema — making "rendering" and "execution" same-source. This is the largest non-trivial change to existing modules (sdl_generator,introspection,query_executor,resolver,loader/registry), but the observable behavior for non-federating single-process apps is unchanged. -
RelationshipInfo boolean matrix → kind discriminator: the local/remote × list/paginated boolean matrix was collapsed into a single
kinddiscriminator (plustarget_service), simplifying executor routing of remote relationships. -
known limitations:
- Federation is read-only this release (no cross-service writes/mutations).
- Cross-service joins are single-field (composite keys are rejected by validation).
- Join-key types are limited to
{str, int, float, bool, UUID};Decimalis rejected atfederate()— root cause: the member'spage_bybuckets rows by SQL column value while the federation wire carries string keys, so a non-string-wire type likeDecimalcan't match a bucket (UUID passes only because SQLite stores UUID columns as strings). This is the documented type-gap fromspecs/013. - A federated pagination order profile is single-column (multi-column
PageOrderis rejected at member startup). - Order/direction selection covers only the β path (GraphQL direct query into the mounter); choosing order from the γ path (Resolver / UseCase business-code composition) is explicitly out of scope for this release.
-
Cross-service bare type names must be unique (duplicates fail-fast at startup), and the federation runs on an internal trusted network (no auth / multi-tenant passthrough this release). A member adding fields at runtime is not hot-detected — restart the entry service to re-init.
-
compatibility:
- Monolith: zero regression. Without
federate, the SQLModel GraphQL surface, Resolver / Core-API path, and Voyager are unchanged (the single-process test suite stays green). The 5.0 major bump is for the major new federation surface, not for breakage.
4.0
4.0.0 (2026-7-20)
- breaking change:
-
SQLModel GraphQL root restructured from flat fields to per-entity grouping (#115): A
@query/@mutationmethod likeUser.get_by_idis now queried as{ User { get_by_id(id: 1){} } }instead of the flat{ userGetById(id: 1){} }. The rootQuery/Mutationmount one field per entity (User: UserQuery!), and each method lives on a synthesized{Entity}Query/{Entity}Mutationgroup type under its original Python name (no camelCase, no entity prefix) — mirroring the UseCaseServiceService { method {} }convention so the query surface stays legible as entities and methods grow. Auto-generatedby_id/by_filterand pagination land inside the group automatically; pagination itself is unaffected (it only wraps relationship fields, never method returns or root fields). Selecting a bare entity group ({ User }) returns a friendlyBARE_GROUP_FIELDerror listing the available methods. Name collisions the old flat prefix used to mask are now rejected eagerly at startup (DuplicateEntityError,DuplicateMethodError,ReservedMethodFieldError,ReservedEntityError). The UseCaseService/compose GraphQL path is unchanged.Migration: rewrite query strings —
entityPrefixMethodCamel(e.g.userGetById) →{ Entity { method {} } }(e.g.{ User { by_id {} } }); response paths nest one level deeper (data["userGetById"]→data["User"]["by_id"]). MCP toolsget_query_schema/get_mutation_schemanow take explicitentity+method(was a single flatname);list_queries/list_mutationsreturn{entity, method, name}entries. -
feat:
-
demo launcher regrouped + MCP added to the paginated blog demo (#115):
start_all.shis reorganized around the README's two-pillar model (Query surface / Core API / Business-logic / Visualization), prints per-port capability tags (GraphQL / MCP / REST / Voyager), and lists every endpoint. The paginated blog demo now serves both GraphQL and MCP on one port (/graphql+/mcp/mcp). -
fix:
- multi-app MCP demo crashed at startup since 3.7.0 (#115):
demo/multi_app/mcp_server.pysubscripted the self-containedApplicationobject as a dict (app['name']), raisingTypeError: 'Application' object is not subscriptableon boot after 3.7.0'sApplicationrefactor — fixed to attribute access. - paginated blog demo moved off port 8005 (#115): Windows reserves 8005 for Windows Hello, so browsers visiting the demo on :8005 popped a "sign in to access this site" dialog. Moved to 8015.
3.7
3.7.0 (2026-7-19)
- feat:
-
Self-contained
Applicationclass (specs/009-app-self-contained):nexusx.mcp.Applicationis now the minimal exportable unit of a "business app" in the MCP system. AnApplicationbundles a SQLModelbase+ one of three mutually-exclusive connection inputs (url/engine/session_factory, or none) + GraphQL metadata, so it can be shipped as an independent Python package (pip install blog-app) and assembled into a gateway viacreate_multi_app_mcp_server(apps=[...]). This fully decouples the app's schema/connection definition from the MCP server that runs it.Key designs: resource ownership follows the source — the
url=path owns the engine it creates anddispose()tears it down;engine=/session_factory=are treated as external anddispose()is a no-op (avoids double-dispose, releasing foreign engines, and leaks). Three connection forms are mutually exclusive, validated at construction (ValueError("Provide at most one of: url, engine, session_factory")). Schema-only mode — construct with no connection args for pure schema introspection (entity_names/ SDL), the key case for distributing an app as a package whose author doesn't know the user's DB URL. Idempotentdispose()guards three overlapping exits (MCP server lifespan shutdown,async with Application(...), explicitawait dispose()). URL credentials are redacted in__repr__and error messages (via SQLAlchemymake_url().render_as_string(hide_password=True)). Cross-app name conflicts fail at construction inMultiAppManagerrather than at runtime.from nexusx.mcp import Application, create_multi_app_mcp_server # Single app standalone (schema introspection + direct query) blog = Application(name="blog", base=BlogBase, url="sqlite+aiosqlite:///blog.db") print(blog.resources.entity_names) result = await blog.resources.handler.execute("{ users { id name } }") # Multiple apps composed into one MCP server mcp = create_multi_app_mcp_server(apps=[blog, shop], name="Gateway") mcp.run() # lifespan exit disposes all owned engines -
deprecation:
-
dict-based
AppConfig→Application: The legacy dict form ({"name": ..., "base": ..., "url": ...}) still works but emits aDeprecationWarningon every construction, and will be removed in 3.8.0. The dict is transparently coerced to anApplicationwith identical behavior, so migration is a purely mechanical swap:# Before (deprecated) create_multi_app_mcp_server(apps=[{"name": "blog", "base": BlogBase, "url": "..."}], name="Gateway") # After create_multi_app_mcp_server(apps=[Application(name="blog", base=BlogBase, url="...")], name="Gateway")Compatibility window: 3.7.x keeps the dict form with a warning; 3.8.0 removes it. Migrate before upgrading to 3.8.
-
fix:
-
use_case router no longer flattens nested
BaseModelparams to dict (#110, fixes #107): Handlers generated bycreate_routerusedbody.model_dump(), flattening the FastAPI-validated request model into a dict before calling the service method. When a method signature declared nestedBaseModelparams (list[ItemInput]/ItemInput/ItemInput | None), the body actually receivedlist[dict]/dict— the signature lied at runtime anditem.textraisedAttributeError. The fix switches to per-field attribute access (getattr(body, name)), so the nestedBaseModelinstances Pydantic built during validation are passed through unchanged. Scalars /list[scalar]/FromContext/ defaults / aliases are unaffected, as are the OpenAPI and compose-schema paths (schema generation and the router are orthogonal). -
behavior change:
route_optionsrejects router-reserved keys (#111): Cleanup following #107. Most of the change is internal and user-invisible:_make_handlerextracts shared logic, drops a dead branch (elif pname not in kwargswas always true because body and context params are disjoint) and an unused parameter, and gives generated handler closures meaningful__name__/__qualname__(better tracebacks and FastAPI-generated operation_ids). The one observable change: passingendpointormethodsinroute_optionspreviously raised a confusingTypeError: got multiple values for keyword argumentat route registration; it now raises a clearValueErroratcreate_router()time.pathand other keys remain overridable.
3.6
3.6.2 (2026-7-15)
- fix:
@query/@mutationreturning bare scalars orlist[scalar]is no longer wrapped as{"_value": ...}(#106): The fallback branch of_serialize_itemstuffed every "non-Pydantic-model + no sub-selection" return value into{"_value": str(item)}— a defensive holdover from when the function was entity-only. Common mutation patterns returning bare scalars (delete_xxx() -> bool,count_xxx() -> int,reorder() -> list[UUID]) have no sub-selection, so they all hit this branch and produced{"_value": "True"}/[{"_value": "..."}]— matching neither the SDL-declaredBoolean!nor a JSON-native value. This was the symmetric gap left by the 3.6.0/3.6.1 UUID work: the input direction was fixed, but method return values on the output direction were still broken. The fallback now routes through_serialize_scalar_value, which uses Pydantic'sTypeAdapter(type(value)).dump_python(value, mode="json")to handlebool/int/strnatively, stringifyUUID, and coverdatetime/Decimal/Enum/set/tuple/nested lists uniformly. Behavior change (strictly a fix): responses for bare-scalar returns change from{"_value": str(...)}to the raw value (true/42/"hello"/["uuid1", "uuid2"]). The{"_value": ...}shape never matched the SDL contract, so no real client could have depended on it.
3.6.1 (2026-7-14)
- fix:
list[UUID]/list[datetime]argument types no longer pass through unconverted (#105): 3.6.0 fixed single-UUID input conversion, butArgumentBuilder._convert_scalar_valuereturnedvalueas-is for genericlist[T]/List[T]targets — the whole list passed through with elements unconverted. So@mutation reorder(cls, ids: list[UUID])received alist[str], and SQLModel/SQLAlchemy raisedAttributeError: 'str' object has no attribute 'hex'when binding a UUID column, with a traceback that didn't point at nexusx. The blast radius was wider than UUID:list[datetime]/list[date]/list[time]args had the same defect, just unexposed because nobody had written such a signature yet. Fixed generically rather than UUID-specifically:_convert_scalar_valuenow recurses intolist(detect viatyping.get_origin, convert each element via itself). Empty lists,Optional[list[T]], and element-levelOptional[T]all fall out naturally; existing bare-scalar branches are unchanged.
3.6.0 (2026-7-14)
- breaking change:
-
UUID promoted to a real GraphQL UUID scalar (#104): Python's
uuid.UUIDwas previously split across two GraphQL paths and wrong on both. The main path (sdl_generator+TypeConverter) rendered UUID fields/args asStringvia anor "String"fallback; the compose path (compose_type_mapper) mapped UUID toID. Both diverged from the explicituuid.UUIDtype. Worse on the input direction: a@querydeclaringid: UUIDreceived astrat runtime (ArgumentBuilderonly recognizeddatetime/date/time), and SQLModel/SQLAlchemy crashed withAttributeError: 'str' object has no attribute 'hex'on UUID-column binding — a traceback that didn't point at nexusx.UUID is now a first-class scalar on both paths, on par with
DateTime/Date/Time: SDL rendersUUID/UUID!, transport is unchanged (still string-serialized), and introspection advertises theUUIDscalar in__schema.Breaking impact: Main path — UUID fields change from
StringtoUUID; sinceStringwas a fallback bug rather than a contract, main-path users perceive this as a fix (SDL type name changes, but SQLModel apps go from crashing to working). Compose path — UUID fields change fromIDtoUUID; this is the genuinely breaking surface, as SDL consumers likegraphql-codegen/Apollo see a new scalar name and must regenerate types. Transport (string serialization) is unchanged — purely an SDL surface change.
3.5
3.5.3 (2026-7-10)
- breaking change:
- Removed the
Opwrapper layer from UseCase compose queries: Compose queries previously required a virtual top-levelOpfield —{ Op { UserService { list_users { id } } } }. The wrapper had no business meaning; it was a placeholder from an early implementation aligning to graphql-core's defaultQueryroot, forcing every query one indent deeper and making every MCP example and doc explain it. Queries now start directly at the service:{ UserService { list_users { id } } }.execute_compose_query, the MCP Layer 3compose_querytool, and the GraphiQL endpoint all reject the old shape uniformly, with errorService 'Op' not found in app '<name>'. Available: [...]. The change is localized to_execute_operations, which now dispatchesselections.items()straight to services instead of unwrapping a rootFieldSelectionfirst.
3.5.2 (2026-7-3)
- fix:
- Voyager node switching no longer leaves an orange border residue on the old node (#101):
graph-ui.js::highlightSchemaBannersetstroke/stroke-width/fillviasetAttributeand stashed the originals indata-original-*DOM attributes, butclearSchemaBannersonlyremoveAttributed those data attributes without writing the originals back to the SVG attributes — it relied ongraphviz.svg.js::restoreElement, whose data source (a jQuery fill+stroke snapshot taken at init, without stroke-width) was independent of graph-ui.js's writes. After switching nodes the title background restored correctly, but a faint orange stroke lingered on the outer frame. Fix:clearSchemaBannersnow reads thedata-original-*attributes and writes them back to the SVG attributes before removing them, making graph-ui.js the last writer that overrides any inconsistency. graphviz.svg.js (vendored) is untouched; restore semantics are "write back the original value" rather than "repaint a fixed color", so theme/dark-mode changes stay compatible.
3.5.1 (2026-7-3)
- fix:
- Voyager ER diagram: Related Entities sub-graph now refreshes when display options change (#100): After
renderErDiagramre-rendered the main graph, the Related Entities sub-graph wasn't refetched —onGeneratedispatched only to the main path, andfetchRelatedEntitieshad a dedup guard (spec 005 FR-011) that short-circuited whenselectedSchema === schemaName. So toggling any display option while the sub-graph was open left it on stale data. Fix: after the main render, if a sub-graph is open, clearselectedSchema(bypassing the dedup guard) and refetch. The dedup guard still fires for genuine repeated clicks on the same entity with no config change.
3.5.0 (2026-7-3)
- feat:
- Voyager ER diagram: "Hide Reverse Relationships" toggle (#99): SQLModel bidirectional relationships (
Relationship(back_populates=...)) are split by SQLAlchemy into two opposite directions — a MANYTOONE (FK holder → referenced) and an ONETOMANY (the reverse mirror). Voyager previously drew both, so every bidirectional pair showed two redundant edges and the canvas got dense. The new toggle keeps only MANYTOONE (and MANYTOMANY) edges and hides the ONETOMANY mirrors, halving the edge count between any pair. Filtering happens at the entry ofErDiagramDotBuilder._add_relationship_linkvia an early return onRelationshipInfo.direction == 'ONETOMANY'— no anchor/label logic changes, and surviving edges look identical, just fewer. Field tables are unaffected (only edges are trimmed). The sub-graph follows automatically. The preference persists tolocalStorage(hide_reverse_relationships, default off for backward compat); the Pydantic payload field defaults toFalseso old clients behave identically.
3.4
3.4.2 (2026-7-2)
- fix:
- SDL now emits
limit/offsetargs on paginated list fields (#98): Withenable_pagination=True, the introspection path renderedlimit/offsetargs on paginated list fields, but the SDL generator emitted the same field with no args — two inconsistent descriptions of one schema. GraphQL requires field args to be declared in SDL or clients can't pass them, so pagination was effectively unreachable for any client consuming SDL (AI agents, codegen, GraphiQL alternatives, doc generators). Fix:SDLGenerator._generate_entity_typereuses_is_paginated_relationshipand emits{field}(limit: Int, offset: Int = 0): {Type}Result!forpage_loader-bearing list fields; other fields are unchanged.offset's default0matches introspection'sdefaultValue: "0".
3.4.1 (2026-7-1)
- feat:
- Voyager ER diagram:
Better Cluster Displaytoggle (for module clusters): Large ER diagrams with "Show Module Cluster" on suffered from severe edge rerouting and unstable in-cluster rendering. A new toggle — visible only in ER-diagram mode with Show Module Cluster enabled — applies a set of Graphviz routing params better suited to large clustered graphs:splines=polyline,newrank=true,compound=true. When off, original Graphviz behavior is preserved. The toggle sits indented under Show Module Cluster, persists tolocalStorage, and auto-hides + resets when Show Module Cluster turns off.
3.4.0 (2026-7-1)
- feat:
- Voyager ER diagram: "About" tab (docstring + Mermaid) & wider sidebar (#95): The sidebar opened on double-click previously had only Fields / Source Code / Related Entities tabs; the schema model's
__doc__had no entry point. A new leftmost About tab renders the class docstring as GitHub-Flavored Markdown (headings, lists, tables, code blocks, blockquotes, rules), with``mermaid fences (stateDiagram-v2/flowchart/sequenceDiagram/…) rendered inline. Malformed mermaid blocks degrade to an error notice + collapsed source (copy-out friendly); a single block failing doesn't break the rest. The sidebar drag width rose from a fixed 800px tofloor(viewport × 2/3)(300px floor preserved), auto-clamping on resize. The docstring is served by a dedicatedPOST /docstringendpoint (mirroring/source//vscode-link) to avoid bloating the initial ER payload; it's rendered throughmarked→DOMPurify→ hardened links (target="_blank" rel="noopener noreferrer") and is read-only.marked/dompurify/mermaid` load from CDN and are pre-cached by the service worker for offline use.
3.3
3.3.1 (2026-6-30)
- fix:
- Voyager ER-diagram sidebar: field descriptions restored:
ErDiagramDotBuilder._get_entity_fieldsdroppeddescfor both field kinds — plainmodel_fieldsdidn't readv.description, and relationship fields lost theirdescriptionwhen converted toRelationshipInfo. So the sidebar Fields table's Description column was always empty even when the schema declaredField(description=...)orCustomRelationship(description=...). The sibling DTO path (get_pydantic_fields) was always correct, so non-ER views were unaffected (which hid the bug). Fix: passdesc=getattr(v, 'description', None) or ''on plain fields (matchingtype_helper.py), and add adescriptionfield toRelationshipInfopropagated fromRelationship.descriptionfor relationship fields. ORM-auto-discovered relationships still have no description (expected).
3.3.0 (2026-6-30)
- feat:
- Voyager ER diagram: "Related Entities" focused sub-graph tab (#93): In large schemas (30+ entities) like
demo/enterprise_voyager, "click to highlight one-hop neighbors" scatters related nodes across the canvas. A new third sidebar tab Related Entities renders a read-only mini ER sub-graph containing only the selected entity, its direct neighbors, and the edges between them. The sub-graph reuses the main graph's render config (module cluster / methods / edge length) and re-renders on config or selection change; it exposes no config of its own. The visual pipeline mirrors the main graph (backend returns DOT, front-end renders via an independent d3-graphviz instance so zoom/pan/layout state don't interfere). It's read-only (no click/dblclick rebinding) but keeps pan/zoom. Isolated entities render as a lone node with a "no direct relationships" notice. - fix:
- Sidebar follows canvas selection; blank-click vs drag-gesture separated (#93): While building the Related Entities tab, two sidebar responsiveness issues were found and fixed: (1) after opening the sidebar via double-click, single-clicking another entity on the canvas didn't update the sidebar (only double-click did — counterintuitive); (2) clicking blank canvas closed the sidebar, but so did drag-panning. Single-click now updates the sidebar when it's open (idempotent with the double-click path); blank-click close uses a mousedown/mouseup 5px movement threshold so pure clicks close but drags (pan/box-select) don't. A protective comment was added to
resetState()documenting that tab selection must persist across entity switches.
3.2
3.2.3 (2026-6-29)
- fix:
- Voyager source-location now accepts fully-qualified class names outside the service module:
VoyagerContext._resolve_objectrestricted module names to the service module's scope, but Voyager/ER UI node names can be any type collected during graph analysis — DTOs, entities, loaders, test helper schemas — often defined outside the service module. Names liketests.test_voyager_security._LocalSchemawere wrongly rejected, surfacing as "invalid format" or broken VS Code links. Fix: drop the service-module allowlist, resolve loaded modules fromsys.modulesfirst, fall back to import, returnNoneon import failure. TheService.methodpath is preserved.
3.2.2 (2026-6-29)
- fix:
- Self-/mutually-referential DTOs no longer stack-overflow schema construction (#91):
ComposeTypeMapper._register_object/_register_input_objectwrote theTypeInfoto its memos only after walking all fields, so self-referential (parent: Self | None,children: list[Self]) or mutually-referential (A.b: B+B.a: A) DTOs recursed into the self-reference before the memo existed, ran toRecursionError, and crashedbuild_compose_schemaat startup. Fix: write afields=()stubTypeInfointo both memos before the field walk, so re-entry short-circuits on the memo; after the walk,dataclasses.replace(stub, fields=...)fills the real fields (TypeInfois frozen).TypeRefis name-based, so downstream consumers always see the finalized version. - SQLModel
date/timefields natively supported end-to-end in GraphQL (#92): SQLModel entities declaringwhen: date/start_time: timewere treated as strings across the GraphQL chain — SDL misreportedString!, mutation calls passed the string through to SQLModel triggeringTypeError: SQLite Date type only accepts Python date objects, and GraphiQL introspection never exposedDate/Timescalars. Three local fixes routedate/timethrough the same path asdatetime:TypeConverter.SCALAR_TYPE_MAPaddsdate → "Date"/time → "Time"(SDL and introspection stop falling back toStringautomatically);ArgumentBuilder._convert_scalar_valueaddsdate/timebranches usingdate.fromisoformat/time.fromisoformat; the hardcoded scalar list inIntrospectionGenerator._build_scalar_typesadds"Date"/"Time"so GraphiQL can discover them.
3.2.1 (2026-6-26)
- fix:
serialize_resultnow uses JSON mode + recursive dict serialization (#90): Port of pydantic-resolve v5.10.4.use_case/serialization.py:serialize_result— the sole serialization path for JSON-RPC and CLI response assembly — failed for non-JSON-native types (UUID/datetime/Decimal), especially when a use case method returned a dict payload containing them:model_dump()defaulted tomode="python"(leaving them as Python objects), dicts were returned without recursion (leaking nested UUID/BaseModel), and the catch-all bypassed JSON conversion. Callers hitTypeError: Object of type UUID is not JSON serializableatjson.dumps, pointing away fromserialize_result. Fix: everything goes through Pydantic JSON mode —model_dump(mode="json"), recursive dict ({k: serialize_result(v) …}), and aTypeAdapter(type(result)).dump_python(result, mode="json")catch-all. The function previously had no unit tests; 12 were added covering the regression core (dict-with-UUID) and adjacent cases.
3.2.0 (2026-6-26)
- feat:
-
Non-SQLModel root objects (virtual entities) (#87): Plain
pydantic.BaseModelsubclasses are now first-class participants in NexusX resolution and ER visualization, without a SQLModel subclass or underlying table. PreviouslyDefineSubsetrequired a SQLModel source, so using a non-ORM root (FastAPICurrentUserfrom OIDC claims, page wrappers, third-party SDK DTOs) required hacking_subset_registry. Three capabilities land together:ErManager.add_virtual_entities([...])registers plain BaseModel subclasses into the ER graph as peers of SQLModel entities — usable asResolverroots, able to declare__relationships__, participating in ExposeAs/SendTo/Collector cross-layer flows, and rendered as visually-distinct virtual nodes. Must be called before the firstcreate_resolver()(the registry freezes afterward; a later call raisesRuntimeError). SQLModel classes are rejected here (they still go through__init__'sentities=/base=); duplicates raiseValueError; non-classes/non-BaseModels raiseTypeError.DefineSubset.__subset__source widens fromtype[SQLModel]totype[BaseModel]— "subset" is a schema-level concept (selecting fields frommodel_fields), independent of data source. The two APIs are orthogonal: a BaseModel can be (a) only a virtual entity, (b) only a DefineSubset source, (c) both, (d) neither.- ER/Voyager render virtual entities with a yellow fill (
#FFF9C4), a«virtual»UML stereotype, and a dashedcluster_virtualsubgraph — visually distinct from DB-backed entities, readable in black-and-white print. Two entry points:ErDiagram.from_er_manager(er)(data API,.to_mermaid()) andErDiagramDotBuilder(er).render_dot()(DOT path, used by Voyager). With zero virtual entities the DOT output is byte-identical to baseline.
Supporting changes:
_resolve_source()unifies source lookup for three root kinds (DefineSubset DTO, registered virtual entity, unregistered BaseModel); an unregistered BaseModel declaring__relationships__now raises a clearRuntimeErrorpointing toadd_virtual_entitiesinstead of silently skipping (spec Edge Case B). CUSTOM-relationship loader output is projected to the declared DTO type viaisinstance(r, dto_cls)(previouslyisinstance(r, BaseModel), which silently skipped projection). DefineSubset auto-includes__relationships__FK fields for BaseModel sources (mirroring SQLModel FK auto-include). Migration: the old_subset_registry[X] = Yhack maps toadd_virtual_entities/DefineSubset/ plain-virtual depending on intent. Seedocs/guide/virtual_entities.mdanddocs/reference/migration.md.
3.1
3.1.3 (2026-6-24)
- fix:
- Pagination
has_moreoff-by-one (#86): Pagination loaders reportedhas_more=Falsewhen exactly one row remained after the current page, so clients stopped paging and the last row was silently dropped. Root cause: M2O/M2M paths computedhas_next_page = total_count > offset + 1 + effective_limit, counting the SQL peek-by-1 row as "already returned"; correct semantics istotal_count > offset + effective_limit. Fix: use the already-fetched peek row directly —has_next_page = len(grouped[fk]) > effective_limit. The peek-by-1 design exists precisely to answer this; deriving fromtotal_countreintroduced the off-by-one.total_countnow only populates the response payload, not the boolean. (E.g.total=5, offset=0, limit=4: wasFalse❌, nowTrue.) - behavior change:
enable_paginationnow warn-skips instead of all-or-nothing blocking startup (#83):ErManager(enable_pagination=True)previously required every ORM list relationship to configureorder_by, else startup raisedValueError— forcing placeholderorder_by="id"on audit logs, append-only event streams, config dicts, or abandoning pagination entirely. Startup now logs a WARNING per skippedEntity.fieldand proceeds. Skipped relationships use the regular loader and render as[T]!(notResult<T>) automatically — those paths already branch onpage_loader is not None, zero downstream change. WARNING (not silent) so users can see which lists were skipped and filter the log if desired. Nostrict_paginationopt-in or explicit opt-out added (YAGNI).
3.1.2 (2026-6-24, untagged)
Port of pydantic-resolve v5.10.2 (184886d) — three INPUT_OBJECT correctness fixes for the UseCase compose surface. Before this release nexusx registered every Pydantic BaseModel as a GraphQL OBJECT whether it appeared as a method return or argument, violating the GraphQL spec (input types must be INPUT_OBJECT) and crashing with DuplicateTypeError when the same class was used as both.
- fix:
- BaseModel method args are now registered as
INPUT_OBJECT(US1):@mutation create_task(payload: CreateTaskInput)previously registeredCreateTaskInputasOBJECT, so GraphiQL refused to render andgraphql.build_client_schemafailed. NewComposeTypeMapper.map_python_type_as_input(py_type)routes method args;_map_leafdispatches BaseModel leaves to a new_register_input_object(kind=INPUT_OBJECT, populatesinput_fields); each input field'sdefault_valuecomes from the pydantic field default rendered as a GraphQL literal. Mutable defaults (default_factory) remain unsupported. - The same BaseModel as both return and arg no longer crashes (US2):
upsert_task(patch: TaskDTO) -> TaskDTOpreviously raisedDuplicateTypeError(bare class name taken by the return side).build_compose_schemais now two-phase: register all return-side OBJECTs first, then all arg-side INPUT_OBJECTs (phase order is load-bearing — only with OBJECT taking the bare name does the input-side rename branch fire). On conflict (bare name taken by OBJECT+python_class is cls), the input type auto-renames to{Name}Input(e.g.TaskDTO→TaskDTOInput). Distinct classes sharing__name__still raiseDuplicateTypeError. Nested inputs close over consistently. - Method-level SDL now expands
INPUT_OBJECTtypes (US3):render_method_sdlpreviously collected only the return closure and treatedINPUT_OBJECTas a leaf, so SDL referencedinput CreateTaskInput { ... }without ever defining it — readers/AI agents saw incomplete SDL._collect_closurenow recurses intoinput_fields;_render_method_sdlcollects closures from both return and arg type-refs;_emit_type_sdlreadsinput_fieldsforINPUT_OBJECTand rendersname: Type = literaldefaults. - spec-compliance:
- GraphiQL canonical introspection round-trip: A hard gate feeds GraphiQL's standard startup introspection query through
compose_introspect, thengraphql.build_client_schema— any spec violation (INPUT_OBJECT field misplaced on OBJECT, dangling type ref, malformed default) makesbuild_client_schemaraise. Regression invariants explicitly assert that apps with no BaseModel args introduce zeroINPUT_OBJECTTypeInfo and that SCALAR/ENUM TypeInfo never carrypython_class.
3.1.1 (2026-6-24)
- fix:
_orm_to_dtopreserves DB NULL (BUG_1_2):Resolver._orm_to_dtofiltered outNone, so DB NULL was silently replaced by the DTO field'sField(default=...). NULL and "explicit default" became indistinguishable in API responses — "unrated" vs "rated zero" both showed 0; timestamps withdefault_factory=datetime.nowlost meaning. Fix: pass field values (including None) straight through; if the DTO field isn'tOptional, Pydantic validation raises (a correct schema-mismatch signal). Only the auto-load path (_orm_to_dto) is affected; directDTO.model_validate(orm)is unchanged.- QueryExecutor per-field exceptions are now logged (BUG_1_3): When the resolver raised (e.g. an
AttributeErrorinside a@querymethod),QueryExecutoronly stuffed the message into the responseerrorsand wrote no log —query_executor.pydidn't evenimport logging. Server bugs were invisible to log-based alerting (Sentry/Loki/CloudWatch). Fix: the per-field except still returns the GraphQL-spec{message, path}but additionally callslogger.exception(...), so traceback + exception type + line land in server logs. Response shape unchanged. post_default_handler+default_handlerfield-conflict detection (BUG_1_6):post_default_handleris a reserved name (a finalizer running after allpost_*, not auto-bound to a field), but thepost_<field>convention strongly implied it would fill adefault_handlerfield. Defining both silently dropped the method's return and left the field at default — zero warning; the constant wasn't even exported. Now defining both raisesValueErrorfrom_build_class_metawith three fix paths (rename method / drop field / assign manually); either alone behaves as before.- chore:
- CLAUDE.md slimmed to pure behavior constraints: CLAUDE.md previously held ~200 lines of tech details (stack, structure, public API list, commands, pitfalls) duplicating
pyproject.toml/__init__.py/source and drifting (review found version/dir/API lists 2 majors stale). Simplified to a 17-line "dev notes" section; all tech detail now lives in source/pyproject as single source; CLAUDE.md added to.gitignore. (Review test infra moved out oftests/.)
3.1.0 (2026-6-23)
- feat:
- Resolver
loader_instancesparameter: Port of pydantic-resolve'sResolver(loader_instances=...). Callers pass pre-created (typically primed) DataLoader instances, matched by class, to skip redundant batch calls for known keys.Resolver(loader_instances={LoaderClass: instance})returns the caller's instance when aresolve_*method declaresloader=Loader(Cls)withClsin the dict; otherwise behavior is unchanged. Construction validates that keys areaiodataloader.DataLoadersubclasses and values are instances of the key (raisesTypeErrorat construction, never entering traversal). Instances are used by reference (not copied) and not cleaned up byresolve()— the caller owns the lifecycle.ErManager.create_resolver()forwardsloader_instances. Scope: only the explicitLoader(Cls)Depends path; auto-load (custom relationships, ORM relationships) is untouched. Strictly equivalent to pydantic-resolve; only difference isTypeError(vs upstream'sAttributeError). - chore:
- Tree-wide
ruff --fix+ uv.lock sync (PR #82):ruff check --fixcleaned 49 lint issues (mostly redundant inlinefrom typing import Annotatedin benchmarks/demo/tests already imported at top).uv.lock: nexusx 3.0.0 → 3.0.1 (a prior bump had missed the lock). No mirror source introduced (stillpypi.org). 16Optional[X] → X | Nonehints needing--unsafe-fixeswere left.
3.0
3.0.1 (2026-6-23)
- fix:
use_case.clino longer hard-requirestyperat import:use_case/cli.pydidtry: import typer except ImportError: raiseat module top, soimport nexusx(via thenexusx.use_casepackage) eagerly pulledtypereven if the CLI was never touched — inconsistent with hownexusx.mcpguardsfastmcp(TYPE_CHECKING+ lazy runtime import). Fix:import nexusxno longer eagerly loadstyper; users need not installnexusx[cli]to use non-CLI entry points.create_use_case_cli()is unchanged (lazy-importstyperon first call). Public API surface unchanged.
3.0.0 (2026-6-20)
- breaking change:
- Removed the old direct-call UseCase MCP entry points: Introduced a new execution chain where
UseCaseServiceauto-generates a real GraphQL schema and builds the MCP service on top (mirroring pydantic-resolve's compose). Two old direct-call use_case MCP entry points (invoke-Python-method-with-JSON-args) are hard-removed. The GraphQL/MCP-orthogonalcreate_use_case_router(FastAPI REST) andcreate_use_case_voyager(visualization) are unchanged. Removed:create_use_case_mcp_server(4-layer MCP, Layer 3 =call_use_casedirect method call) →create_use_case_mcp_server(Layer 3 =compose_querytaking a GraphQL string);create_use_case_flat_server(one-method-one-tool flat MCP) →create_use_case_mcp_server;ServiceIntrospector(SDL-style strings) →ComposeSchema(real introspection JSON + SDL). Migration:docs/migrations/3.0-use-case-graphql.md. Version: strict semver — public API removal = major (2.10.1 → 3.0.0). - feat:
- UseCase GraphQL + 4-layer MCP: New public API:
create_use_case_mcp_server(apps, name)(4-layer progressive disclosure:list_apps→describe_compose_schema→describe_compose_method→compose_query);build_compose_schema(app) -> ComposeSchema;ComposeSchema(render_introspection()/render_sdl()/render_method_sdl());compose_introspect(schema, query)(GraphiQL-style introspection, paired with MCP Layer 3 which rejects introspection — MCP uses progressive disclosure, HTTP GraphiQL uses full introspection);ComposeSchemaErrorand subclasses. Fixed three-layer schema (Query→*ServiceQuery→ methods). Layer 3 takes a standard GraphQL string and rejects introspection (__schema/__type/__typename), returning{data: null, errors: [...]}to steer exploration to Layers 1/2. Execution boundary: the GraphQL layer does not wrap the service method's return in anotherResolver— the method already doesResolver().resolve(dtos)internally; the outer layer only calls the method → field projection (subset.build_subset_model) → serialization. - preserved (unchanged):
UseCaseService/BusinessMeta/@query/@mutation/FromContext/UseCaseAppConfig,create_use_case_router,create_use_case_jsonrpc_router,create_use_case_voyager, and all GraphQL-mode capabilities (GraphQLHandler/SDLGenerator/existingmcp/).