Skip to content

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 log and 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 raised ALIAS_CONFLICT instead. QueryParser.parse_ operations() now keeps per-operation trees, the entity-first executor executes exactly ONE operation per request (operationName matches a named operation, a single anonymous operation runs as-is, several without a name errors per spec), and the operation_name the handler always accepted (the federation transport always passed it) finally takes effect. compose_query requires 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 null with MUTATION_FAILED (entity-first: RESOLVER_ERROR), and every later mutation in the same operation is skipped and marked SKIPPED_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 an ALIAS_CONFLICT error 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_FAILED error entry (carrying extensions.service_method) — on compose the whole response used to become data: 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[].path now uses response keys (the alias when present), consistent with the data keys 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)) downgraded CancelledError to a QUERY_FAILED error entry — a cancelled request (client disconnect, asyncio.timeout) would keep executing its serial mutations and return a normal response. Cancellation now propagates: Exception becomes a per-field error, any other BaseException is 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: true with 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: {} in data — a misleading "empty success" for clients that read data and ignore errors. Method return types are non-null, so the failure now nulls the whole group (GraphQL null propagation at group granularity) and the error entry carries extensions.code = RESOLVER_ERROR for machine-side classification.

  • page_by_*_in order falls back to default_order (#139): The synthesized federation batch root rendered order as optional in SDL, but the function body hard-read kwargs["order"]KeyError for any SDL-direct caller (e.g. an MCP agent) that omitted it. The mounter always sent order explicitly, so federation e2e tests never exercised the omission path. The body now falls back to page_config.default_order on None (invalid strings still fail with Unknown-order-profile), and the __signature__ Parameter carries default= 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_tool are removed. These were internal wiring helpers with no documented usage; the FastMCP servers themselves are unaffected. Use create_single_app_mcp_server or 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_name now cluster under their own name on both the ER diagram and the UseCase page (DTOs registered via dto_classes follow their member), and the opt-in ErManager(color=...) fills the cluster background. Ownership priority is federation service > member service_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 --select field projection (#136): Every command generated by create_use_case_cli now takes --select for 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 same apply_selection engine as MCP compose_query, so CLI and MCP share one selection mechanism. A method's --help now lists the return DTO's fields (nested relationships marked selectable), so you know what --select can pick. parse_selection accepts 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_pages and SubsetConfig.federation_join_key. Member federation capability is now declared on the entity:
    • __federation_keys__ — which fields are federation batch entry keys; each generates a by_<key>_in root (WHERE key IN (values)).
    • __pagination_orders__ — the single order-profile carrier: a key in __federation_keys__ additionally yields a page_by_<key>_in root, 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_keyfederation_key (a selector for multi-key entities, auto for a single key). AutoQueryConfig now holds only toggles (default_limit, generate_by_id, ...).
  • Federation pagination auto-detected, RemoteRelationship.pagination removed (specs/021, #134): The per-edge RemoteRelationship(pagination=True/False) parameter is gone. Federation pagination is now auto-detected: the mounter probes the member's page_by_<join_remote>_in root (from __pagination_orders__) and wires the paged loader; the query's limit/order drive top-N at runtime. enable_pagination stays 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 forgets pagination=True).
  • Deprecated API cleanup — AppConfig dict + AutoQueryConfig(session_factory): Removed the legacy AppConfig dict form for create_mcp_server apps (use Application instances). Removed AutoQueryConfig(session_factory) positional compat (pass session_factory to GraphQLHandler / Application instead).
  • create_* factory naming unified: create_mcp_servercreate_multi_app_mcp_server; create_simple_mcp_servercreate_single_app_mcp_server (symmetric multi/single); create_jsonrpc_routercreate_use_case_jsonrpc_router (use_case prefix consistency).

  • feat:

  • DTO auto-discovery — federation_public without dto_classes (#135): A DTO with federation_public=True is now auto-discovered from its source entity (__subset__.kls) — no need to pass dto_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 - Alpha with 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 to 5 - Production/Stable and rewrote the description to reflect the current positioning (batch-loaded query graph + multi-protocol methods + "for people and AI"). Also collapsed the duplicate dev dependency 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 with GraphQLHandler / Application er_manager= injection and a LoaderRegistry Protocol upgraded from an ErManager alias 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 named items (Order ── items ── OrderItem) was misread as a paginated package and the parent field was silently dropped — items was an undocumented reserved word. Producers now return a typed PaginatedPackage / KeyedPaginatedPackage; the build path reads RelationshipInfo.is_paginated; consumers branch on isinstance. 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. UseCaseVoyager takes the fed_registry (fed from er_manager._fed_registry via VoyagerContext), tags materialized remote types by service, and reuses the existing Renderer coloring path; the ER diagram path is unchanged and the front-end needs no change (coloring lives in the DOT template).

  • fix:

  • get_return_type re-resolves placeholder DefineSubset (#131): A method return annotation is evaluated at def time, so a RemoteRef-sourced DefineSubset froze its placeholder class into the annotation; federate later 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_type now re-resolves each class by name against the method's module globals (mirroring federate's setattr); 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 DefineSubset DTO 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 via Resolver(context=...). Member values are read-only; the mounter adds derived fields via its own resolve_*/post_*. See the β-vs-γ table in docs/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 temporary use_response_builder flag is removed (new path is the only path); flag-on/off equivalence tested.

  • Unified core_builder (specs/021): build_response_model (entity-first) and build_subset_model (UseCase compose) are now thin shells over one recursive builder, core_builder.build_model, parameterized by a FieldResolver Protocol that abstracts the only real difference — where a nested field's type comes from (SQLModel relationships vs pydantic DTO annotations). FieldResolution.nested_shape collapses the former is_list/is_optional/nested_annotation_factory triple into one callable. SelectionError moved down to core_builder (no reverse-import of use_case; re-exported for back-compat).

  • Declarative Paged field 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's default_order.

  • Auto-derive federation_join_key: When a federation-public DTO's subset has exactly one foreign-key field, federation_join_key is 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 Paged params (not PageLoadCommand objects the remote loader can't unwrap); graphql Undefined/non-wire values can never reach the wire; an items-only paged selection no longer leaks internal RowMapping columns.
  • Mapped[list[X]] relationship is_list misclassification + regression tests.
  • Preserve nullable and paged remote-relationship shapes through the builder.

  • refactor:

  • fetch_dto_subtreeprepare_dto_loader: the γ primitive returns a prepared loader (it does not fetch); rename makes the β/γ asymmetry honest. Added set_remote_page_params so all three remote side-channels now have explicit setters.
  • Direction demoted out of the top-level __all__ (no production touchpoint; still importable from nexusx.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-time order/direction selection, 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 query comments(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_orders validates profiles (enum-safe names, single-column, SQL column, direction, nullable nulls, default∈keys) at startup; the local page_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 fixed sort_field; SDL/introspection render the order enum + direction via the kind-agnostic federation_order_enum_layout (local pagination rides the same render path as REMOTE_PAGED); and the executor injects order/direction from selection.arguments into 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 Relationship is not extensible — Relationship(page_orders=...) raises TypeError (verified at implement time, correcting the spec's initial "extend Relationship" wording). __pagination_orders__ coexists with order_by (profile takes precedence; order_by is 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/013 gap): 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 pydantic ValidationError. It materialized the remote comments field as a flat list[Comment], but the member returned a {items, pagination} package, so model_validate rejected the dict. Root cause: ER introspection dropped the member's local-pagination state — RelDescriptor.pagination only flagged federation remote pagination (RemoteRelationship(pagination=True) with a target_service), so the mounter never saw comments was 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's REMOTE_COALESCED + pagination branch (pagination_schema.py), and the executor's REMOTE_COALESCED → skip load (query_executor.py) — only the introspection signal was missing. Fix: serialize_er_introspection also marks a relationship paginated when it is a local list with a page_loader set, so the mounter materializes a package slot, renders comments(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 a RemoteRelationship in __relationships__ and run await 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 over RelationshipInfo (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, materializes
    

    Data fetching is done by RemoteLoader, a DataLoader that turns a batch of join keys + a nested selection into one GraphQL query against the member's by_<join_remote>_in root and returns the rows grouped/aligned to the DataLoader's positional contract (missing keys map to None for to-one / [] for to-many). Remote types are materialized at init time (not lazily at runtime) as bare-named pydantic classes via a two-pass FederatedTypeRegistry (BFS pull of reachable ER fragments with a visited-set, then topological create_model + model_rebuild against 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.target takes a RemoteRef (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. DefineSubset accepts a RemoteRef as its source too, and resolve_deferred_subsets (run during federate) resolves each to the materialized class. Seven classes of misconfiguration fail-fast at startup: unknown service prefix, missing typename, missing/incompatible join field, missing by_<key>_in root, duplicate service name, duplicate cross-service bare type name, and a mount-graph cycle. Federation requires the optional nexusx[federation] extra (httpx); calling federate() without httpx raises an informative ImportError.

  • Member-side offset pagination for remote to-many relationships (specs/013): A mounted to-many relationship can opt into member-side pagination via RemoteRelationship(..., 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 in AutoQueryConfig.batch_pagesBatchPageConfig(default_order, orders={profile: PageOrder})PageOrder(terms=[OrderTerm])OrderTerm(field, direction, nulls), and the member exposes a page_by_<key>_in(keys, limit, offset, order) root that paginates per-parent with a window ROW_NUMBER(). The business client only ever passes limit / offset; total_count is computed only when selected; primary-key columns not in the profile are appended as a deterministic tie-breaker; a malformed remote response raises RemoteQueryError while 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 root by_<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 GraphQL order enum (values = profile names, default = the member's default_order) plus a mounter-owned global direction enum (ASC / DESC) on the paginated relationship field, so a client flips ordering per-query without the member predefining multiple relationships or redeploying. The member's page_by_<key>_in gains a direction argument; flipping it overrides each profile term's default direction and nulls follow the flip (desc + nulls_lastasc + 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.order is removed (the sole source of order is now the query argument), federate validation is relaxed (pagination=True no longer requires order; instead it requires the member's page_capability.orders to be non-empty), and a PageOrder is constrained to a single OrderTerm (multi-column profiles are rejected at member startup).

    {
      Product {
        by_filter {
          reviews(limit: 5, offset: 0, order: HIGHEST_RATING, direction: DESC) {
            items { title rating }
            pagination { has_more total_count }
          }
        }
      }
    }
    
  • 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-in RemoteService("reviews", url=..., color=...) tints a member's cluster.

  • internal:

  • Schema generation is now registry-driven: SDL generation and __schema introspection were switched from get_type_hints-based field discovery to reading the ErManager registry, 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 kind discriminator (plus target_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}; Decimal is rejected at federate() — root cause: the member's page_by buckets rows by SQL column value while the federation wire carries string keys, so a non-string-wire type like Decimal can't match a bucket (UUID passes only because SQLite stores UUID columns as strings). This is the documented type-gap from specs/013.
  • A federated pagination order profile is single-column (multi-column PageOrder is 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/@mutation method like User.get_by_id is now queried as { User { get_by_id(id: 1){} } } instead of the flat { userGetById(id: 1){} }. The root Query/Mutation mount one field per entity (User: UserQuery!), and each method lives on a synthesized {Entity}Query/{Entity}Mutation group type under its original Python name (no camelCase, no entity prefix) — mirroring the UseCaseService Service { method {} } convention so the query surface stays legible as entities and methods grow. Auto-generated by_id/by_filter and 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 friendly BARE_GROUP_FIELD error 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 tools get_query_schema/get_mutation_schema now take explicit entity + method (was a single flat name); list_queries/list_mutations return {entity, method, name} entries.

  • feat:

  • demo launcher regrouped + MCP added to the paginated blog demo (#115): start_all.sh is 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.py subscripted the self-contained Application object as a dict (app['name']), raising TypeError: 'Application' object is not subscriptable on boot after 3.7.0's Application refactor — 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 Application class (specs/009-app-self-contained): nexusx.mcp.Application is now the minimal exportable unit of a "business app" in the MCP system. An Application bundles a SQLModel base + 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 via create_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 and dispose() tears it down; engine= / session_factory= are treated as external and dispose() 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. Idempotent dispose() guards three overlapping exits (MCP server lifespan shutdown, async with Application(...), explicit await dispose()). URL credentials are redacted in __repr__ and error messages (via SQLAlchemy make_url().render_as_string(hide_password=True)). Cross-app name conflicts fail at construction in MultiAppManager rather 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 AppConfigApplication: The legacy dict form ({"name": ..., "base": ..., "url": ...}) still works but emits a DeprecationWarning on every construction, and will be removed in 3.8.0. The dict is transparently coerced to an Application with 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 BaseModel params to dict (#110, fixes #107): Handlers generated by create_router used body.model_dump(), flattening the FastAPI-validated request model into a dict before calling the service method. When a method signature declared nested BaseModel params (list[ItemInput] / ItemInput / ItemInput | None), the body actually received list[dict] / dict — the signature lied at runtime and item.text raised AttributeError. The fix switches to per-field attribute access (getattr(body, name)), so the nested BaseModel instances 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_options rejects router-reserved keys (#111): Cleanup following #107. Most of the change is internal and user-invisible: _make_handler extracts shared logic, drops a dead branch (elif pname not in kwargs was 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: passing endpoint or methods in route_options previously raised a confusing TypeError: got multiple values for keyword argument at route registration; it now raises a clear ValueError at create_router() time. path and other keys remain overridable.

3.6

3.6.2 (2026-7-15)

  • fix:
  • @query/@mutation returning bare scalars or list[scalar] is no longer wrapped as {"_value": ...} (#106): The fallback branch of _serialize_item stuffed 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-declared Boolean! 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's TypeAdapter(type(value)).dump_python(value, mode="json") to handle bool/int/str natively, stringify UUID, and cover datetime/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, but ArgumentBuilder._convert_scalar_value returned value as-is for generic list[T] / List[T] targets — the whole list passed through with elements unconverted. So @mutation reorder(cls, ids: list[UUID]) received a list[str], and SQLModel/SQLAlchemy raised AttributeError: '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_value now recurses into list (detect via typing.get_origin, convert each element via itself). Empty lists, Optional[list[T]], and element-level Optional[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.UUID was previously split across two GraphQL paths and wrong on both. The main path (sdl_generator + TypeConverter) rendered UUID fields/args as String via an or "String" fallback; the compose path (compose_type_mapper) mapped UUID to ID. Both diverged from the explicit uuid.UUID type. Worse on the input direction: a @query declaring id: UUID received a str at runtime (ArgumentBuilder only recognized datetime/date/time), and SQLModel/SQLAlchemy crashed with AttributeError: '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 renders UUID / UUID!, transport is unchanged (still string-serialized), and introspection advertises the UUID scalar in __schema.

    Breaking impact: Main path — UUID fields change from String to UUID; since String was 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 from ID to UUID; this is the genuinely breaking surface, as SDL consumers like graphql-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 Op wrapper layer from UseCase compose queries: Compose queries previously required a virtual top-level Op field — { Op { UserService { list_users { id } } } }. The wrapper had no business meaning; it was a placeholder from an early implementation aligning to graphql-core's default Query root, 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 3 compose_query tool, and the GraphiQL endpoint all reject the old shape uniformly, with error Service 'Op' not found in app '<name>'. Available: [...]. The change is localized to _execute_operations, which now dispatches selections.items() straight to services instead of unwrapping a root FieldSelection first.

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::highlightSchemaBanner set stroke/stroke-width/fill via setAttribute and stashed the originals in data-original-* DOM attributes, but clearSchemaBanners only removeAttributed those data attributes without writing the originals back to the SVG attributes — it relied on graphviz.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: clearSchemaBanners now reads the data-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 renderErDiagram re-rendered the main graph, the Related Entities sub-graph wasn't refetched — onGenerate dispatched only to the main path, and fetchRelatedEntities had a dedup guard (spec 005 FR-011) that short-circuited when selectedSchema === 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, clear selectedSchema (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 of ErDiagramDotBuilder._add_relationship_link via an early return on RelationshipInfo.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 to localStorage (hide_reverse_relationships, default off for backward compat); the Pydantic payload field defaults to False so old clients behave identically.

3.4

3.4.2 (2026-7-2)

  • fix:
  • SDL now emits limit/offset args on paginated list fields (#98): With enable_pagination=True, the introspection path rendered limit/offset args 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_type reuses _is_paginated_relationship and emits {field}(limit: Int, offset: Int = 0): {Type}Result! for page_loader-bearing list fields; other fields are unchanged. offset's default 0 matches introspection's defaultValue: "0".

3.4.1 (2026-7-1)

  • feat:
  • Voyager ER diagram: Better Cluster Display toggle (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 to localStorage, 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 throughmarkedDOMPurify→ 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_fields dropped desc for both field kinds — plain model_fields didn't read v.description, and relationship fields lost their description when converted to RelationshipInfo. So the sidebar Fields table's Description column was always empty even when the schema declared Field(description=...) or CustomRelationship(description=...). The sibling DTO path (get_pydantic_fields) was always correct, so non-ER views were unaffected (which hid the bug). Fix: pass desc=getattr(v, 'description', None) or '' on plain fields (matching type_helper.py), and add a description field to RelationshipInfo propagated from Relationship.description for 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_object restricted 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 like tests.test_voyager_security._LocalSchema were wrongly rejected, surfacing as "invalid format" or broken VS Code links. Fix: drop the service-module allowlist, resolve loaded modules from sys.modules first, fall back to import, return None on import failure. The Service.method path 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_object wrote the TypeInfo to 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 to RecursionError, and crashed build_compose_schema at startup. Fix: write a fields=() stub TypeInfo into 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 (TypeInfo is frozen). TypeRef is name-based, so downstream consumers always see the finalized version.
  • SQLModel date/time fields natively supported end-to-end in GraphQL (#92): SQLModel entities declaring when: date/start_time: time were treated as strings across the GraphQL chain — SDL misreported String!, mutation calls passed the string through to SQLModel triggering TypeError: SQLite Date type only accepts Python date objects, and GraphiQL introspection never exposed Date/Time scalars. Three local fixes route date/time through the same path as datetime: TypeConverter.SCALAR_TYPE_MAP adds date → "Date" / time → "Time" (SDL and introspection stop falling back to String automatically); ArgumentBuilder._convert_scalar_value adds date/time branches using date.fromisoformat/time.fromisoformat; the hardcoded scalar list in IntrospectionGenerator._build_scalar_types adds "Date"/"Time" so GraphiQL can discover them.

3.2.1 (2026-6-26)

  • fix:
  • serialize_result now 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 to mode="python" (leaving them as Python objects), dicts were returned without recursion (leaking nested UUID/BaseModel), and the catch-all bypassed JSON conversion. Callers hit TypeError: Object of type UUID is not JSON serializable at json.dumps, pointing away from serialize_result. Fix: everything goes through Pydantic JSON mode — model_dump(mode="json"), recursive dict ({k: serialize_result(v) …}), and a TypeAdapter(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.BaseModel subclasses are now first-class participants in NexusX resolution and ER visualization, without a SQLModel subclass or underlying table. Previously DefineSubset required a SQLModel source, so using a non-ORM root (FastAPI CurrentUser from 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 as Resolver roots, able to declare __relationships__, participating in ExposeAs/SendTo/Collector cross-layer flows, and rendered as visually-distinct virtual nodes. Must be called before the first create_resolver() (the registry freezes afterward; a later call raises RuntimeError). SQLModel classes are rejected here (they still go through __init__'s entities=/base=); duplicates raise ValueError; non-classes/non-BaseModels raise TypeError.
    • DefineSubset.__subset__ source widens from type[SQLModel] to type[BaseModel] — "subset" is a schema-level concept (selecting fields from model_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 dashed cluster_virtual subgraph — visually distinct from DB-backed entities, readable in black-and-white print. Two entry points: ErDiagram.from_er_manager(er) (data API, .to_mermaid()) and ErDiagramDotBuilder(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 clear RuntimeError pointing to add_virtual_entities instead of silently skipping (spec Edge Case B). CUSTOM-relationship loader output is projected to the declared DTO type via isinstance(r, dto_cls) (previously isinstance(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] = Y hack maps to add_virtual_entities / DefineSubset / plain-virtual depending on intent. See docs/guide/virtual_entities.md and docs/reference/migration.md.

3.1

3.1.3 (2026-6-24)

  • fix:
  • Pagination has_more off-by-one (#86): Pagination loaders reported has_more=False when exactly one row remained after the current page, so clients stopped paging and the last row was silently dropped. Root cause: M2O/M2M paths computed has_next_page = total_count > offset + 1 + effective_limit, counting the SQL peek-by-1 row as "already returned"; correct semantics is total_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 from total_count reintroduced the off-by-one. total_count now only populates the response payload, not the boolean. (E.g. total=5, offset=0, limit=4: was False ❌, now True.)
  • behavior change:
  • enable_pagination now warn-skips instead of all-or-nothing blocking startup (#83): ErManager(enable_pagination=True) previously required every ORM list relationship to configure order_by, else startup raised ValueError — forcing placeholder order_by="id" on audit logs, append-only event streams, config dicts, or abandoning pagination entirely. Startup now logs a WARNING per skipped Entity.field and proceeds. Skipped relationships use the regular loader and render as [T]! (not Result<T>) automatically — those paths already branch on page_loader is not None, zero downstream change. WARNING (not silent) so users can see which lists were skipped and filter the log if desired. No strict_pagination opt-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 registered CreateTaskInput as OBJECT, so GraphiQL refused to render and graphql.build_client_schema failed. New ComposeTypeMapper.map_python_type_as_input(py_type) routes method args; _map_leaf dispatches BaseModel leaves to a new _register_input_object (kind=INPUT_OBJECT, populates input_fields); each input field's default_value comes 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) -> TaskDTO previously raised DuplicateTypeError (bare class name taken by the return side). build_compose_schema is 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. TaskDTOTaskDTOInput). Distinct classes sharing __name__ still raise DuplicateTypeError. Nested inputs close over consistently.
  • Method-level SDL now expands INPUT_OBJECT types (US3): render_method_sdl previously collected only the return closure and treated INPUT_OBJECT as a leaf, so SDL referenced input CreateTaskInput { ... } without ever defining it — readers/AI agents saw incomplete SDL. _collect_closure now recurses into input_fields; _render_method_sdl collects closures from both return and arg type-refs; _emit_type_sdl reads input_fields for INPUT_OBJECT and renders name: Type = literal defaults.
  • spec-compliance:
  • GraphiQL canonical introspection round-trip: A hard gate feeds GraphiQL's standard startup introspection query through compose_introspect, then graphql.build_client_schema — any spec violation (INPUT_OBJECT field misplaced on OBJECT, dangling type ref, malformed default) makes build_client_schema raise. Regression invariants explicitly assert that apps with no BaseModel args introduce zero INPUT_OBJECT TypeInfo and that SCALAR/ENUM TypeInfo never carry python_class.

3.1.1 (2026-6-24)

  • fix:
  • _orm_to_dto preserves DB NULL (BUG_1_2): Resolver._orm_to_dto filtered out None, so DB NULL was silently replaced by the DTO field's Field(default=...). NULL and "explicit default" became indistinguishable in API responses — "unrated" vs "rated zero" both showed 0; timestamps with default_factory=datetime.now lost meaning. Fix: pass field values (including None) straight through; if the DTO field isn't Optional, Pydantic validation raises (a correct schema-mismatch signal). Only the auto-load path (_orm_to_dto) is affected; direct DTO.model_validate(orm) is unchanged.
  • QueryExecutor per-field exceptions are now logged (BUG_1_3): When the resolver raised (e.g. an AttributeError inside a @query method), QueryExecutor only stuffed the message into the response errors and wrote no logquery_executor.py didn't even import 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 calls logger.exception(...), so traceback + exception type + line land in server logs. Response shape unchanged.
  • post_default_handler + default_handler field-conflict detection (BUG_1_6): post_default_handler is a reserved name (a finalizer running after all post_*, not auto-bound to a field), but the post_<field> convention strongly implied it would fill a default_handler field. 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 raises ValueError from _build_class_meta with 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 of tests/.)

3.1.0 (2026-6-23)

  • feat:
  • Resolver loader_instances parameter: Port of pydantic-resolve's Resolver(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 a resolve_* method declares loader=Loader(Cls) with Cls in the dict; otherwise behavior is unchanged. Construction validates that keys are aiodataloader.DataLoader subclasses and values are instances of the key (raises TypeError at construction, never entering traversal). Instances are used by reference (not copied) and not cleaned up by resolve() — the caller owns the lifecycle. ErManager.create_resolver() forwards loader_instances. Scope: only the explicit Loader(Cls) Depends path; auto-load (custom relationships, ORM relationships) is untouched. Strictly equivalent to pydantic-resolve; only difference is TypeError (vs upstream's AttributeError).
  • chore:
  • Tree-wide ruff --fix + uv.lock sync (PR #82): ruff check --fix cleaned 49 lint issues (mostly redundant inline from typing import Annotated in 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 (still pypi.org). 16 Optional[X] → X | None hints needing --unsafe-fixes were left.

3.0

3.0.1 (2026-6-23)

  • fix:
  • use_case.cli no longer hard-requires typer at import: use_case/cli.py did try: import typer except ImportError: raise at module top, so import nexusx (via the nexusx.use_case package) eagerly pulled typer even if the CLI was never touched — inconsistent with how nexusx.mcp guards fastmcp (TYPE_CHECKING + lazy runtime import). Fix: import nexusx no longer eagerly loads typer; users need not install nexusx[cli] to use non-CLI entry points. create_use_case_cli() is unchanged (lazy-imports typer on 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 UseCaseService auto-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-orthogonal create_use_case_router (FastAPI REST) and create_use_case_voyager (visualization) are unchanged. Removed: create_use_case_mcp_server (4-layer MCP, Layer 3 = call_use_case direct method call) → create_use_case_mcp_server (Layer 3 = compose_query taking 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_appsdescribe_compose_schemadescribe_compose_methodcompose_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); ComposeSchemaError and 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 another Resolver — the method already does Resolver().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/existing mcp/).