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.

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_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_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_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_mcp_server(apps=[{"name": "blog", "base": BlogBase, "url": "..."}], name="Gateway")
    # After
    create_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_graphql_mcp_server (Layer 3 = compose_query taking a GraphQL string); create_use_case_flat_server (one-method-one-tool flat MCP) → create_use_case_graphql_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_graphql_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_jsonrpc_router, create_use_case_voyager, and all GraphQL-mode capabilities (GraphQLHandler/SDLGenerator/existing mcp/).