Changelog
- Major (X.0.0): Breaking changes or major new surface
- Minor (x.Y.0): New features, backward compatible
- Patch (x.y.Z): Bug fixes and minor improvements
Pre-3.0 history is not included here. See
git logand the historical tags for changes before 3.0.0.
4.0
4.0.0 (2026-7-20)
- breaking change:
-
SQLModel GraphQL root restructured from flat fields to per-entity grouping (#115): A
@query/@mutationmethod likeUser.get_by_idis now queried as{ User { get_by_id(id: 1){} } }instead of the flat{ userGetById(id: 1){} }. The rootQuery/Mutationmount one field per entity (User: UserQuery!), and each method lives on a synthesized{Entity}Query/{Entity}Mutationgroup type under its original Python name (no camelCase, no entity prefix) — mirroring the UseCaseServiceService { method {} }convention so the query surface stays legible as entities and methods grow. Auto-generatedby_id/by_filterand pagination land inside the group automatically; pagination itself is unaffected (it only wraps relationship fields, never method returns or root fields). Selecting a bare entity group ({ User }) returns a friendlyBARE_GROUP_FIELDerror listing the available methods. Name collisions the old flat prefix used to mask are now rejected eagerly at startup (DuplicateEntityError,DuplicateMethodError,ReservedMethodFieldError,ReservedEntityError). The UseCaseService/compose GraphQL path is unchanged.Migration: rewrite query strings —
entityPrefixMethodCamel(e.g.userGetById) →{ Entity { method {} } }(e.g.{ User { by_id {} } }); response paths nest one level deeper (data["userGetById"]→data["User"]["by_id"]). MCP toolsget_query_schema/get_mutation_schemanow take explicitentity+method(was a single flatname);list_queries/list_mutationsreturn{entity, method, name}entries. -
feat:
-
demo launcher regrouped + MCP added to the paginated blog demo (#115):
start_all.shis reorganized around the README's two-pillar model (Query surface / Core API / Business-logic / Visualization), prints per-port capability tags (GraphQL / MCP / REST / Voyager), and lists every endpoint. The paginated blog demo now serves both GraphQL and MCP on one port (/graphql+/mcp/mcp). -
fix:
- multi-app MCP demo crashed at startup since 3.7.0 (#115):
demo/multi_app/mcp_server.pysubscripted the self-containedApplicationobject as a dict (app['name']), raisingTypeError: 'Application' object is not subscriptableon boot after 3.7.0'sApplicationrefactor — fixed to attribute access. - paginated blog demo moved off port 8005 (#115): Windows reserves 8005 for Windows Hello, so browsers visiting the demo on :8005 popped a "sign in to access this site" dialog. Moved to 8015.
3.7
3.7.0 (2026-7-19)
- feat:
-
Self-contained
Applicationclass (specs/009-app-self-contained):nexusx.mcp.Applicationis now the minimal exportable unit of a "business app" in the MCP system. AnApplicationbundles a SQLModelbase+ one of three mutually-exclusive connection inputs (url/engine/session_factory, or none) + GraphQL metadata, so it can be shipped as an independent Python package (pip install blog-app) and assembled into a gateway viacreate_mcp_server(apps=[...]). This fully decouples the app's schema/connection definition from the MCP server that runs it.Key designs: resource ownership follows the source — the
url=path owns the engine it creates anddispose()tears it down;engine=/session_factory=are treated as external anddispose()is a no-op (avoids double-dispose, releasing foreign engines, and leaks). Three connection forms are mutually exclusive, validated at construction (ValueError("Provide at most one of: url, engine, session_factory")). Schema-only mode — construct with no connection args for pure schema introspection (entity_names/ SDL), the key case for distributing an app as a package whose author doesn't know the user's DB URL. Idempotentdispose()guards three overlapping exits (MCP server lifespan shutdown,async with Application(...), explicitawait dispose()). URL credentials are redacted in__repr__and error messages (via SQLAlchemymake_url().render_as_string(hide_password=True)). Cross-app name conflicts fail at construction inMultiAppManagerrather than at runtime.from nexusx.mcp import Application, create_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
AppConfig→Application: The legacy dict form ({"name": ..., "base": ..., "url": ...}) still works but emits aDeprecationWarningon every construction, and will be removed in 3.8.0. The dict is transparently coerced to anApplicationwith identical behavior, so migration is a purely mechanical swap:# Before (deprecated) create_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
BaseModelparams to dict (#110, fixes #107): Handlers generated bycreate_routerusedbody.model_dump(), flattening the FastAPI-validated request model into a dict before calling the service method. When a method signature declared nestedBaseModelparams (list[ItemInput]/ItemInput/ItemInput | None), the body actually receivedlist[dict]/dict— the signature lied at runtime anditem.textraisedAttributeError. The fix switches to per-field attribute access (getattr(body, name)), so the nestedBaseModelinstances Pydantic built during validation are passed through unchanged. Scalars /list[scalar]/FromContext/ defaults / aliases are unaffected, as are the OpenAPI and compose-schema paths (schema generation and the router are orthogonal). -
behavior change:
route_optionsrejects router-reserved keys (#111): Cleanup following #107. Most of the change is internal and user-invisible:_make_handlerextracts shared logic, drops a dead branch (elif pname not in kwargswas always true because body and context params are disjoint) and an unused parameter, and gives generated handler closures meaningful__name__/__qualname__(better tracebacks and FastAPI-generated operation_ids). The one observable change: passingendpointormethodsinroute_optionspreviously raised a confusingTypeError: got multiple values for keyword argumentat route registration; it now raises a clearValueErroratcreate_router()time.pathand other keys remain overridable.
3.6
3.6.2 (2026-7-15)
- fix:
@query/@mutationreturning bare scalars orlist[scalar]is no longer wrapped as{"_value": ...}(#106): The fallback branch of_serialize_itemstuffed every "non-Pydantic-model + no sub-selection" return value into{"_value": str(item)}— a defensive holdover from when the function was entity-only. Common mutation patterns returning bare scalars (delete_xxx() -> bool,count_xxx() -> int,reorder() -> list[UUID]) have no sub-selection, so they all hit this branch and produced{"_value": "True"}/[{"_value": "..."}]— matching neither the SDL-declaredBoolean!nor a JSON-native value. This was the symmetric gap left by the 3.6.0/3.6.1 UUID work: the input direction was fixed, but method return values on the output direction were still broken. The fallback now routes through_serialize_scalar_value, which uses Pydantic'sTypeAdapter(type(value)).dump_python(value, mode="json")to handlebool/int/strnatively, stringifyUUID, and coverdatetime/Decimal/Enum/set/tuple/nested lists uniformly. Behavior change (strictly a fix): responses for bare-scalar returns change from{"_value": str(...)}to the raw value (true/42/"hello"/["uuid1", "uuid2"]). The{"_value": ...}shape never matched the SDL contract, so no real client could have depended on it.
3.6.1 (2026-7-14)
- fix:
list[UUID]/list[datetime]argument types no longer pass through unconverted (#105): 3.6.0 fixed single-UUID input conversion, butArgumentBuilder._convert_scalar_valuereturnedvalueas-is for genericlist[T]/List[T]targets — the whole list passed through with elements unconverted. So@mutation reorder(cls, ids: list[UUID])received alist[str], and SQLModel/SQLAlchemy raisedAttributeError: 'str' object has no attribute 'hex'when binding a UUID column, with a traceback that didn't point at nexusx. The blast radius was wider than UUID:list[datetime]/list[date]/list[time]args had the same defect, just unexposed because nobody had written such a signature yet. Fixed generically rather than UUID-specifically:_convert_scalar_valuenow recurses intolist(detect viatyping.get_origin, convert each element via itself). Empty lists,Optional[list[T]], and element-levelOptional[T]all fall out naturally; existing bare-scalar branches are unchanged.
3.6.0 (2026-7-14)
- breaking change:
-
UUID promoted to a real GraphQL UUID scalar (#104): Python's
uuid.UUIDwas previously split across two GraphQL paths and wrong on both. The main path (sdl_generator+TypeConverter) rendered UUID fields/args asStringvia anor "String"fallback; the compose path (compose_type_mapper) mapped UUID toID. Both diverged from the explicituuid.UUIDtype. Worse on the input direction: a@querydeclaringid: UUIDreceived astrat runtime (ArgumentBuilderonly recognizeddatetime/date/time), and SQLModel/SQLAlchemy crashed withAttributeError: 'str' object has no attribute 'hex'on UUID-column binding — a traceback that didn't point at nexusx.UUID is now a first-class scalar on both paths, on par with
DateTime/Date/Time: SDL rendersUUID/UUID!, transport is unchanged (still string-serialized), and introspection advertises theUUIDscalar in__schema.Breaking impact: Main path — UUID fields change from
StringtoUUID; sinceStringwas a fallback bug rather than a contract, main-path users perceive this as a fix (SDL type name changes, but SQLModel apps go from crashing to working). Compose path — UUID fields change fromIDtoUUID; this is the genuinely breaking surface, as SDL consumers likegraphql-codegen/Apollo see a new scalar name and must regenerate types. Transport (string serialization) is unchanged — purely an SDL surface change.
3.5
3.5.3 (2026-7-10)
- breaking change:
- Removed the
Opwrapper layer from UseCase compose queries: Compose queries previously required a virtual top-levelOpfield —{ Op { UserService { list_users { id } } } }. The wrapper had no business meaning; it was a placeholder from an early implementation aligning to graphql-core's defaultQueryroot, forcing every query one indent deeper and making every MCP example and doc explain it. Queries now start directly at the service:{ UserService { list_users { id } } }.execute_compose_query, the MCP Layer 3compose_querytool, and the GraphiQL endpoint all reject the old shape uniformly, with errorService 'Op' not found in app '<name>'. Available: [...]. The change is localized to_execute_operations, which now dispatchesselections.items()straight to services instead of unwrapping a rootFieldSelectionfirst.
3.5.2 (2026-7-3)
- fix:
- Voyager node switching no longer leaves an orange border residue on the old node (#101):
graph-ui.js::highlightSchemaBannersetstroke/stroke-width/fillviasetAttributeand stashed the originals indata-original-*DOM attributes, butclearSchemaBannersonlyremoveAttributed those data attributes without writing the originals back to the SVG attributes — it relied ongraphviz.svg.js::restoreElement, whose data source (a jQuery fill+stroke snapshot taken at init, without stroke-width) was independent of graph-ui.js's writes. After switching nodes the title background restored correctly, but a faint orange stroke lingered on the outer frame. Fix:clearSchemaBannersnow reads thedata-original-*attributes and writes them back to the SVG attributes before removing them, making graph-ui.js the last writer that overrides any inconsistency. graphviz.svg.js (vendored) is untouched; restore semantics are "write back the original value" rather than "repaint a fixed color", so theme/dark-mode changes stay compatible.
3.5.1 (2026-7-3)
- fix:
- Voyager ER diagram: Related Entities sub-graph now refreshes when display options change (#100): After
renderErDiagramre-rendered the main graph, the Related Entities sub-graph wasn't refetched —onGeneratedispatched only to the main path, andfetchRelatedEntitieshad a dedup guard (spec 005 FR-011) that short-circuited whenselectedSchema === schemaName. So toggling any display option while the sub-graph was open left it on stale data. Fix: after the main render, if a sub-graph is open, clearselectedSchema(bypassing the dedup guard) and refetch. The dedup guard still fires for genuine repeated clicks on the same entity with no config change.
3.5.0 (2026-7-3)
- feat:
- Voyager ER diagram: "Hide Reverse Relationships" toggle (#99): SQLModel bidirectional relationships (
Relationship(back_populates=...)) are split by SQLAlchemy into two opposite directions — a MANYTOONE (FK holder → referenced) and an ONETOMANY (the reverse mirror). Voyager previously drew both, so every bidirectional pair showed two redundant edges and the canvas got dense. The new toggle keeps only MANYTOONE (and MANYTOMANY) edges and hides the ONETOMANY mirrors, halving the edge count between any pair. Filtering happens at the entry ofErDiagramDotBuilder._add_relationship_linkvia an early return onRelationshipInfo.direction == 'ONETOMANY'— no anchor/label logic changes, and surviving edges look identical, just fewer. Field tables are unaffected (only edges are trimmed). The sub-graph follows automatically. The preference persists tolocalStorage(hide_reverse_relationships, default off for backward compat); the Pydantic payload field defaults toFalseso old clients behave identically.
3.4
3.4.2 (2026-7-2)
- fix:
- SDL now emits
limit/offsetargs on paginated list fields (#98): Withenable_pagination=True, the introspection path renderedlimit/offsetargs on paginated list fields, but the SDL generator emitted the same field with no args — two inconsistent descriptions of one schema. GraphQL requires field args to be declared in SDL or clients can't pass them, so pagination was effectively unreachable for any client consuming SDL (AI agents, codegen, GraphiQL alternatives, doc generators). Fix:SDLGenerator._generate_entity_typereuses_is_paginated_relationshipand emits{field}(limit: Int, offset: Int = 0): {Type}Result!forpage_loader-bearing list fields; other fields are unchanged.offset's default0matches introspection'sdefaultValue: "0".
3.4.1 (2026-7-1)
- feat:
- Voyager ER diagram:
Better Cluster Displaytoggle (for module clusters): Large ER diagrams with "Show Module Cluster" on suffered from severe edge rerouting and unstable in-cluster rendering. A new toggle — visible only in ER-diagram mode with Show Module Cluster enabled — applies a set of Graphviz routing params better suited to large clustered graphs:splines=polyline,newrank=true,compound=true. When off, original Graphviz behavior is preserved. The toggle sits indented under Show Module Cluster, persists tolocalStorage, and auto-hides + resets when Show Module Cluster turns off.
3.4.0 (2026-7-1)
- feat:
- Voyager ER diagram: "About" tab (docstring + Mermaid) & wider sidebar (#95): The sidebar opened on double-click previously had only Fields / Source Code / Related Entities tabs; the schema model's
__doc__had no entry point. A new leftmost About tab renders the class docstring as GitHub-Flavored Markdown (headings, lists, tables, code blocks, blockquotes, rules), with``mermaid fences (stateDiagram-v2/flowchart/sequenceDiagram/…) rendered inline. Malformed mermaid blocks degrade to an error notice + collapsed source (copy-out friendly); a single block failing doesn't break the rest. The sidebar drag width rose from a fixed 800px tofloor(viewport × 2/3)(300px floor preserved), auto-clamping on resize. The docstring is served by a dedicatedPOST /docstringendpoint (mirroring/source//vscode-link) to avoid bloating the initial ER payload; it's rendered throughmarked→DOMPurify→ hardened links (target="_blank" rel="noopener noreferrer") and is read-only.marked/dompurify/mermaid` load from CDN and are pre-cached by the service worker for offline use.
3.3
3.3.1 (2026-6-30)
- fix:
- Voyager ER-diagram sidebar: field descriptions restored:
ErDiagramDotBuilder._get_entity_fieldsdroppeddescfor both field kinds — plainmodel_fieldsdidn't readv.description, and relationship fields lost theirdescriptionwhen converted toRelationshipInfo. So the sidebar Fields table's Description column was always empty even when the schema declaredField(description=...)orCustomRelationship(description=...). The sibling DTO path (get_pydantic_fields) was always correct, so non-ER views were unaffected (which hid the bug). Fix: passdesc=getattr(v, 'description', None) or ''on plain fields (matchingtype_helper.py), and add adescriptionfield toRelationshipInfopropagated fromRelationship.descriptionfor relationship fields. ORM-auto-discovered relationships still have no description (expected).
3.3.0 (2026-6-30)
- feat:
- Voyager ER diagram: "Related Entities" focused sub-graph tab (#93): In large schemas (30+ entities) like
demo/enterprise_voyager, "click to highlight one-hop neighbors" scatters related nodes across the canvas. A new third sidebar tab Related Entities renders a read-only mini ER sub-graph containing only the selected entity, its direct neighbors, and the edges between them. The sub-graph reuses the main graph's render config (module cluster / methods / edge length) and re-renders on config or selection change; it exposes no config of its own. The visual pipeline mirrors the main graph (backend returns DOT, front-end renders via an independent d3-graphviz instance so zoom/pan/layout state don't interfere). It's read-only (no click/dblclick rebinding) but keeps pan/zoom. Isolated entities render as a lone node with a "no direct relationships" notice. - fix:
- Sidebar follows canvas selection; blank-click vs drag-gesture separated (#93): While building the Related Entities tab, two sidebar responsiveness issues were found and fixed: (1) after opening the sidebar via double-click, single-clicking another entity on the canvas didn't update the sidebar (only double-click did — counterintuitive); (2) clicking blank canvas closed the sidebar, but so did drag-panning. Single-click now updates the sidebar when it's open (idempotent with the double-click path); blank-click close uses a mousedown/mouseup 5px movement threshold so pure clicks close but drags (pan/box-select) don't. A protective comment was added to
resetState()documenting that tab selection must persist across entity switches.
3.2
3.2.3 (2026-6-29)
- fix:
- Voyager source-location now accepts fully-qualified class names outside the service module:
VoyagerContext._resolve_objectrestricted module names to the service module's scope, but Voyager/ER UI node names can be any type collected during graph analysis — DTOs, entities, loaders, test helper schemas — often defined outside the service module. Names liketests.test_voyager_security._LocalSchemawere wrongly rejected, surfacing as "invalid format" or broken VS Code links. Fix: drop the service-module allowlist, resolve loaded modules fromsys.modulesfirst, fall back to import, returnNoneon import failure. TheService.methodpath is preserved.
3.2.2 (2026-6-29)
- fix:
- Self-/mutually-referential DTOs no longer stack-overflow schema construction (#91):
ComposeTypeMapper._register_object/_register_input_objectwrote theTypeInfoto its memos only after walking all fields, so self-referential (parent: Self | None,children: list[Self]) or mutually-referential (A.b: B+B.a: A) DTOs recursed into the self-reference before the memo existed, ran toRecursionError, and crashedbuild_compose_schemaat startup. Fix: write afields=()stubTypeInfointo both memos before the field walk, so re-entry short-circuits on the memo; after the walk,dataclasses.replace(stub, fields=...)fills the real fields (TypeInfois frozen).TypeRefis name-based, so downstream consumers always see the finalized version. - SQLModel
date/timefields natively supported end-to-end in GraphQL (#92): SQLModel entities declaringwhen: date/start_time: timewere treated as strings across the GraphQL chain — SDL misreportedString!, mutation calls passed the string through to SQLModel triggeringTypeError: SQLite Date type only accepts Python date objects, and GraphiQL introspection never exposedDate/Timescalars. Three local fixes routedate/timethrough the same path asdatetime:TypeConverter.SCALAR_TYPE_MAPaddsdate → "Date"/time → "Time"(SDL and introspection stop falling back toStringautomatically);ArgumentBuilder._convert_scalar_valueaddsdate/timebranches usingdate.fromisoformat/time.fromisoformat; the hardcoded scalar list inIntrospectionGenerator._build_scalar_typesadds"Date"/"Time"so GraphiQL can discover them.
3.2.1 (2026-6-26)
- fix:
serialize_resultnow uses JSON mode + recursive dict serialization (#90): Port of pydantic-resolve v5.10.4.use_case/serialization.py:serialize_result— the sole serialization path for JSON-RPC and CLI response assembly — failed for non-JSON-native types (UUID/datetime/Decimal), especially when a use case method returned a dict payload containing them:model_dump()defaulted tomode="python"(leaving them as Python objects), dicts were returned without recursion (leaking nested UUID/BaseModel), and the catch-all bypassed JSON conversion. Callers hitTypeError: Object of type UUID is not JSON serializableatjson.dumps, pointing away fromserialize_result. Fix: everything goes through Pydantic JSON mode —model_dump(mode="json"), recursive dict ({k: serialize_result(v) …}), and aTypeAdapter(type(result)).dump_python(result, mode="json")catch-all. The function previously had no unit tests; 12 were added covering the regression core (dict-with-UUID) and adjacent cases.
3.2.0 (2026-6-26)
- feat:
-
Non-SQLModel root objects (virtual entities) (#87): Plain
pydantic.BaseModelsubclasses are now first-class participants in NexusX resolution and ER visualization, without a SQLModel subclass or underlying table. PreviouslyDefineSubsetrequired a SQLModel source, so using a non-ORM root (FastAPICurrentUserfrom OIDC claims, page wrappers, third-party SDK DTOs) required hacking_subset_registry. Three capabilities land together:ErManager.add_virtual_entities([...])registers plain BaseModel subclasses into the ER graph as peers of SQLModel entities — usable asResolverroots, able to declare__relationships__, participating in ExposeAs/SendTo/Collector cross-layer flows, and rendered as visually-distinct virtual nodes. Must be called before the firstcreate_resolver()(the registry freezes afterward; a later call raisesRuntimeError). SQLModel classes are rejected here (they still go through__init__'sentities=/base=); duplicates raiseValueError; non-classes/non-BaseModels raiseTypeError.DefineSubset.__subset__source widens fromtype[SQLModel]totype[BaseModel]— "subset" is a schema-level concept (selecting fields frommodel_fields), independent of data source. The two APIs are orthogonal: a BaseModel can be (a) only a virtual entity, (b) only a DefineSubset source, (c) both, (d) neither.- ER/Voyager render virtual entities with a yellow fill (
#FFF9C4), a«virtual»UML stereotype, and a dashedcluster_virtualsubgraph — visually distinct from DB-backed entities, readable in black-and-white print. Two entry points:ErDiagram.from_er_manager(er)(data API,.to_mermaid()) andErDiagramDotBuilder(er).render_dot()(DOT path, used by Voyager). With zero virtual entities the DOT output is byte-identical to baseline.
Supporting changes:
_resolve_source()unifies source lookup for three root kinds (DefineSubset DTO, registered virtual entity, unregistered BaseModel); an unregistered BaseModel declaring__relationships__now raises a clearRuntimeErrorpointing toadd_virtual_entitiesinstead of silently skipping (spec Edge Case B). CUSTOM-relationship loader output is projected to the declared DTO type viaisinstance(r, dto_cls)(previouslyisinstance(r, BaseModel), which silently skipped projection). DefineSubset auto-includes__relationships__FK fields for BaseModel sources (mirroring SQLModel FK auto-include). Migration: the old_subset_registry[X] = Yhack maps toadd_virtual_entities/DefineSubset/ plain-virtual depending on intent. Seedocs/guide/virtual_entities.mdanddocs/reference/migration.md.
3.1
3.1.3 (2026-6-24)
- fix:
- Pagination
has_moreoff-by-one (#86): Pagination loaders reportedhas_more=Falsewhen exactly one row remained after the current page, so clients stopped paging and the last row was silently dropped. Root cause: M2O/M2M paths computedhas_next_page = total_count > offset + 1 + effective_limit, counting the SQL peek-by-1 row as "already returned"; correct semantics istotal_count > offset + effective_limit. Fix: use the already-fetched peek row directly —has_next_page = len(grouped[fk]) > effective_limit. The peek-by-1 design exists precisely to answer this; deriving fromtotal_countreintroduced the off-by-one.total_countnow only populates the response payload, not the boolean. (E.g.total=5, offset=0, limit=4: wasFalse❌, nowTrue.) - behavior change:
enable_paginationnow warn-skips instead of all-or-nothing blocking startup (#83):ErManager(enable_pagination=True)previously required every ORM list relationship to configureorder_by, else startup raisedValueError— forcing placeholderorder_by="id"on audit logs, append-only event streams, config dicts, or abandoning pagination entirely. Startup now logs a WARNING per skippedEntity.fieldand proceeds. Skipped relationships use the regular loader and render as[T]!(notResult<T>) automatically — those paths already branch onpage_loader is not None, zero downstream change. WARNING (not silent) so users can see which lists were skipped and filter the log if desired. Nostrict_paginationopt-in or explicit opt-out added (YAGNI).
3.1.2 (2026-6-24, untagged)
Port of pydantic-resolve v5.10.2 (184886d) — three INPUT_OBJECT correctness fixes for the UseCase compose surface. Before this release nexusx registered every Pydantic BaseModel as a GraphQL OBJECT whether it appeared as a method return or argument, violating the GraphQL spec (input types must be INPUT_OBJECT) and crashing with DuplicateTypeError when the same class was used as both.
- fix:
- BaseModel method args are now registered as
INPUT_OBJECT(US1):@mutation create_task(payload: CreateTaskInput)previously registeredCreateTaskInputasOBJECT, so GraphiQL refused to render andgraphql.build_client_schemafailed. NewComposeTypeMapper.map_python_type_as_input(py_type)routes method args;_map_leafdispatches BaseModel leaves to a new_register_input_object(kind=INPUT_OBJECT, populatesinput_fields); each input field'sdefault_valuecomes from the pydantic field default rendered as a GraphQL literal. Mutable defaults (default_factory) remain unsupported. - The same BaseModel as both return and arg no longer crashes (US2):
upsert_task(patch: TaskDTO) -> TaskDTOpreviously raisedDuplicateTypeError(bare class name taken by the return side).build_compose_schemais now two-phase: register all return-side OBJECTs first, then all arg-side INPUT_OBJECTs (phase order is load-bearing — only with OBJECT taking the bare name does the input-side rename branch fire). On conflict (bare name taken by OBJECT+python_class is cls), the input type auto-renames to{Name}Input(e.g.TaskDTO→TaskDTOInput). Distinct classes sharing__name__still raiseDuplicateTypeError. Nested inputs close over consistently. - Method-level SDL now expands
INPUT_OBJECTtypes (US3):render_method_sdlpreviously collected only the return closure and treatedINPUT_OBJECTas a leaf, so SDL referencedinput CreateTaskInput { ... }without ever defining it — readers/AI agents saw incomplete SDL._collect_closurenow recurses intoinput_fields;_render_method_sdlcollects closures from both return and arg type-refs;_emit_type_sdlreadsinput_fieldsforINPUT_OBJECTand rendersname: Type = literaldefaults. - spec-compliance:
- GraphiQL canonical introspection round-trip: A hard gate feeds GraphiQL's standard startup introspection query through
compose_introspect, thengraphql.build_client_schema— any spec violation (INPUT_OBJECT field misplaced on OBJECT, dangling type ref, malformed default) makesbuild_client_schemaraise. Regression invariants explicitly assert that apps with no BaseModel args introduce zeroINPUT_OBJECTTypeInfo and that SCALAR/ENUM TypeInfo never carrypython_class.
3.1.1 (2026-6-24)
- fix:
_orm_to_dtopreserves DB NULL (BUG_1_2):Resolver._orm_to_dtofiltered outNone, so DB NULL was silently replaced by the DTO field'sField(default=...). NULL and "explicit default" became indistinguishable in API responses — "unrated" vs "rated zero" both showed 0; timestamps withdefault_factory=datetime.nowlost meaning. Fix: pass field values (including None) straight through; if the DTO field isn'tOptional, Pydantic validation raises (a correct schema-mismatch signal). Only the auto-load path (_orm_to_dto) is affected; directDTO.model_validate(orm)is unchanged.- QueryExecutor per-field exceptions are now logged (BUG_1_3): When the resolver raised (e.g. an
AttributeErrorinside a@querymethod),QueryExecutoronly stuffed the message into the responseerrorsand wrote no log —query_executor.pydidn't evenimport logging. Server bugs were invisible to log-based alerting (Sentry/Loki/CloudWatch). Fix: the per-field except still returns the GraphQL-spec{message, path}but additionally callslogger.exception(...), so traceback + exception type + line land in server logs. Response shape unchanged. post_default_handler+default_handlerfield-conflict detection (BUG_1_6):post_default_handleris a reserved name (a finalizer running after allpost_*, not auto-bound to a field), but thepost_<field>convention strongly implied it would fill adefault_handlerfield. Defining both silently dropped the method's return and left the field at default — zero warning; the constant wasn't even exported. Now defining both raisesValueErrorfrom_build_class_metawith three fix paths (rename method / drop field / assign manually); either alone behaves as before.- chore:
- CLAUDE.md slimmed to pure behavior constraints: CLAUDE.md previously held ~200 lines of tech details (stack, structure, public API list, commands, pitfalls) duplicating
pyproject.toml/__init__.py/source and drifting (review found version/dir/API lists 2 majors stale). Simplified to a 17-line "dev notes" section; all tech detail now lives in source/pyproject as single source; CLAUDE.md added to.gitignore. (Review test infra moved out oftests/.)
3.1.0 (2026-6-23)
- feat:
- Resolver
loader_instancesparameter: Port of pydantic-resolve'sResolver(loader_instances=...). Callers pass pre-created (typically primed) DataLoader instances, matched by class, to skip redundant batch calls for known keys.Resolver(loader_instances={LoaderClass: instance})returns the caller's instance when aresolve_*method declaresloader=Loader(Cls)withClsin the dict; otherwise behavior is unchanged. Construction validates that keys areaiodataloader.DataLoadersubclasses and values are instances of the key (raisesTypeErrorat construction, never entering traversal). Instances are used by reference (not copied) and not cleaned up byresolve()— the caller owns the lifecycle.ErManager.create_resolver()forwardsloader_instances. Scope: only the explicitLoader(Cls)Depends path; auto-load (custom relationships, ORM relationships) is untouched. Strictly equivalent to pydantic-resolve; only difference isTypeError(vs upstream'sAttributeError). - chore:
- Tree-wide
ruff --fix+ uv.lock sync (PR #82):ruff check --fixcleaned 49 lint issues (mostly redundant inlinefrom typing import Annotatedin benchmarks/demo/tests already imported at top).uv.lock: nexusx 3.0.0 → 3.0.1 (a prior bump had missed the lock). No mirror source introduced (stillpypi.org). 16Optional[X] → X | Nonehints needing--unsafe-fixeswere left.
3.0
3.0.1 (2026-6-23)
- fix:
use_case.clino longer hard-requirestyperat import:use_case/cli.pydidtry: import typer except ImportError: raiseat module top, soimport nexusx(via thenexusx.use_casepackage) eagerly pulledtypereven if the CLI was never touched — inconsistent with hownexusx.mcpguardsfastmcp(TYPE_CHECKING+ lazy runtime import). Fix:import nexusxno longer eagerly loadstyper; users need not installnexusx[cli]to use non-CLI entry points.create_use_case_cli()is unchanged (lazy-importstyperon first call). Public API surface unchanged.
3.0.0 (2026-6-20)
- breaking change:
- Removed the old direct-call UseCase MCP entry points: Introduced a new execution chain where
UseCaseServiceauto-generates a real GraphQL schema and builds the MCP service on top (mirroring pydantic-resolve's compose). Two old direct-call use_case MCP entry points (invoke-Python-method-with-JSON-args) are hard-removed. The GraphQL/MCP-orthogonalcreate_use_case_router(FastAPI REST) andcreate_use_case_voyager(visualization) are unchanged. Removed:create_use_case_mcp_server(4-layer MCP, Layer 3 =call_use_casedirect method call) →create_use_case_graphql_mcp_server(Layer 3 =compose_querytaking 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_apps→describe_compose_schema→describe_compose_method→compose_query);build_compose_schema(app) -> ComposeSchema;ComposeSchema(render_introspection()/render_sdl()/render_method_sdl());compose_introspect(schema, query)(GraphiQL-style introspection, paired with MCP Layer 3 which rejects introspection — MCP uses progressive disclosure, HTTP GraphiQL uses full introspection);ComposeSchemaErrorand subclasses. Fixed three-layer schema (Query→*ServiceQuery→ methods). Layer 3 takes a standard GraphQL string and rejects introspection (__schema/__type/__typename), returning{data: null, errors: [...]}to steer exploration to Layers 1/2. Execution boundary: the GraphQL layer does not wrap the service method's return in anotherResolver— the method already doesResolver().resolve(dtos)internally; the outer layer only calls the method → field projection (subset.build_subset_model) → serialization. - preserved (unchanged):
UseCaseService/BusinessMeta/@query/@mutation/FromContext/UseCaseAppConfig,create_use_case_router,create_jsonrpc_router,create_use_case_voyager, and all GraphQL-mode capabilities (GraphQLHandler/SDLGenerator/existingmcp/).