jvspatial 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. {jvspatial-0.1.0/jvspatial.egg-info → jvspatial-0.2.0}/PKG-INFO +3 -2
  2. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/__init__.py +22 -0
  3. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/__init__.py +22 -0
  4. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/annotations.py +3 -1
  5. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/context.py +176 -145
  6. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/entities/node.py +8 -4
  7. jvspatial-0.2.0/jvspatial/core/graph_context_config.py +131 -0
  8. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/graph_expansion.py +107 -46
  9. jvspatial-0.2.0/jvspatial/core/graph_projection.py +1103 -0
  10. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/pager.py +73 -80
  11. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/_cache.py +77 -7
  12. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/_observable.py +143 -28
  13. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/_postgres_translate.py +13 -5
  14. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/_sqlite_translate.py +6 -2
  15. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/database.py +261 -80
  16. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/dynamodb.py +208 -129
  17. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/jsondb.py +130 -138
  18. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/mongodb.py +239 -58
  19. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/postgres.py +278 -121
  20. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/sqlite.py +198 -31
  21. jvspatial-0.2.0/jvspatial/storage/interfaces/_stream.py +55 -0
  22. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/storage/interfaces/local.py +12 -14
  23. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/storage/interfaces/s3.py +11 -18
  24. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/version.py +1 -1
  25. {jvspatial-0.1.0 → jvspatial-0.2.0/jvspatial.egg-info}/PKG-INFO +3 -2
  26. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial.egg-info/SOURCES.txt +3 -0
  27. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial.egg-info/requires.txt +2 -1
  28. {jvspatial-0.1.0 → jvspatial-0.2.0}/pyproject.toml +6 -1
  29. {jvspatial-0.1.0 → jvspatial-0.2.0}/AUTHORS +0 -0
  30. {jvspatial-0.1.0 → jvspatial-0.2.0}/LICENSE +0 -0
  31. {jvspatial-0.1.0 → jvspatial-0.2.0}/README.md +0 -0
  32. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/__init__.py +0 -0
  33. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/_route_utils.py +0 -0
  34. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/_session_store.py +0 -0
  35. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/api_key_service.py +0 -0
  36. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/cleanup.py +0 -0
  37. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/config.py +0 -0
  38. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/enhanced.py +0 -0
  39. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/models.py +0 -0
  40. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/oauth/__init__.py +0 -0
  41. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/oauth/bridge.py +0 -0
  42. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/oauth/client_adapter.py +0 -0
  43. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/oauth/dcr.py +0 -0
  44. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/oauth/denylist.py +0 -0
  45. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/oauth/keys.py +0 -0
  46. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/oauth/metadata.py +0 -0
  47. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/oauth/models.py +0 -0
  48. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/oauth/refresh_store.py +0 -0
  49. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/oauth/requests.py +0 -0
  50. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/oauth/resource.py +0 -0
  51. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/oauth/revocation.py +0 -0
  52. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/oauth/routes.py +0 -0
  53. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/oauth/server.py +0 -0
  54. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/openapi_config.py +0 -0
  55. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/rbac.py +0 -0
  56. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/auth/service.py +0 -0
  57. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/components/__init__.py +0 -0
  58. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/components/app_builder.py +0 -0
  59. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/components/auth_configurator.py +0 -0
  60. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/components/auth_middleware.py +0 -0
  61. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/components/database_configurator.py +0 -0
  62. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/components/endpoint_auth_resolver.py +0 -0
  63. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/components/endpoint_manager.py +0 -0
  64. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/components/error_handler.py +0 -0
  65. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/components/logging_config.py +0 -0
  66. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/components/path_matcher.py +0 -0
  67. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/config.py +0 -0
  68. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/config_groups.py +0 -0
  69. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/constants.py +0 -0
  70. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/context.py +0 -0
  71. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/decorators/__init__.py +0 -0
  72. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/decorators/deferred_registry.py +0 -0
  73. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/decorators/field.py +0 -0
  74. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/decorators/function_wrappers.py +0 -0
  75. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/decorators/route.py +0 -0
  76. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/decorators/route_config.py +0 -0
  77. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/deferred_invoke_route.py +0 -0
  78. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/endpoints/__init__.py +0 -0
  79. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/endpoints/factory.py +0 -0
  80. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/endpoints/graph_visualization.py +0 -0
  81. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/endpoints/metadata.py +0 -0
  82. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/endpoints/registry.py +0 -0
  83. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/endpoints/response.py +0 -0
  84. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/endpoints/router.py +0 -0
  85. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/endpoints/walker_executor.py +0 -0
  86. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/exceptions.py +0 -0
  87. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/integrations/__init__.py +0 -0
  88. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/integrations/scheduler/__init__.py +0 -0
  89. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/integrations/scheduler/decorators.py +0 -0
  90. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/integrations/scheduler/middleware.py +0 -0
  91. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/integrations/scheduler/models.py +0 -0
  92. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/integrations/scheduler/scheduler.py +0 -0
  93. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/integrations/storage/__init__.py +0 -0
  94. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/integrations/storage/service.py +0 -0
  95. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/integrations/webhooks/__init__.py +0 -0
  96. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/integrations/webhooks/helpers.py +0 -0
  97. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/integrations/webhooks/middleware.py +0 -0
  98. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/integrations/webhooks/models.py +0 -0
  99. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/integrations/webhooks/utils.py +0 -0
  100. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/integrations/webhooks/webhook_auth.py +0 -0
  101. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/middleware/__init__.py +0 -0
  102. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/middleware/manager.py +0 -0
  103. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/middleware/rate_limit.py +0 -0
  104. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/middleware/rate_limit_backend.py +0 -0
  105. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/server.py +0 -0
  106. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/server_app_factory.py +0 -0
  107. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/server_configurator.py +0 -0
  108. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/server_lifecycle.py +0 -0
  109. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/server_registration.py +0 -0
  110. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/server_run.py +0 -0
  111. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/services/__init__.py +0 -0
  112. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/services/discovery.py +0 -0
  113. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/services/file_storage.py +0 -0
  114. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/services/lifecycle.py +0 -0
  115. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/utils/__init__.py +0 -0
  116. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/utils/path_utils.py +0 -0
  117. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/api/utils/reload.py +0 -0
  118. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/async_utils/__init__.py +0 -0
  119. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/cache/__init__.py +0 -0
  120. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/cache/base.py +0 -0
  121. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/cache/factory.py +0 -0
  122. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/cache/layered.py +0 -0
  123. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/cache/memory.py +0 -0
  124. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/cache/redis.py +0 -0
  125. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/cli.py +0 -0
  126. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/decorators.py +0 -0
  127. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/entities/__init__.py +0 -0
  128. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/entities/_visit_hooks.py +0 -0
  129. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/entities/edge.py +0 -0
  130. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/entities/node_query.py +0 -0
  131. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/entities/object.py +0 -0
  132. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/entities/root.py +0 -0
  133. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/entities/walker.py +0 -0
  134. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/entities/walker_components/__init__.py +0 -0
  135. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/entities/walker_components/event_system.py +0 -0
  136. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/entities/walker_components/protection.py +0 -0
  137. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/entities/walker_components/trail_store.py +0 -0
  138. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/entities/walker_components/walker_queue.py +0 -0
  139. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/entities/walker_components/walker_trail.py +0 -0
  140. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/events.py +0 -0
  141. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/graph.py +0 -0
  142. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/graph_payload.py +0 -0
  143. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/migrations.py +0 -0
  144. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/mixins/__init__.py +0 -0
  145. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/mixins/deferred_save.py +0 -0
  146. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/utils.py +0 -0
  147. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/core/validate.py +0 -0
  148. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/__init__.py +0 -0
  149. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/_atomic.py +0 -0
  150. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/_path_locks.py +0 -0
  151. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/factory.py +0 -0
  152. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/manager.py +0 -0
  153. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/query.py +0 -0
  154. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/transaction.py +0 -0
  155. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/db/work_claim.py +0 -0
  156. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/dev_tools/__init__.py +0 -0
  157. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/env.py +0 -0
  158. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/env_adapter.py +0 -0
  159. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/exceptions.py +0 -0
  160. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/logging/__init__.py +0 -0
  161. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/logging/config.py +0 -0
  162. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/logging/custom_levels.py +0 -0
  163. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/logging/endpoints.py +0 -0
  164. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/logging/filter_utils.py +0 -0
  165. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/logging/handler.py +0 -0
  166. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/logging/models.py +0 -0
  167. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/logging/service.py +0 -0
  168. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/memory/__init__.py +0 -0
  169. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/observability/__init__.py +0 -0
  170. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/observability/metrics.py +0 -0
  171. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/observability/otel.py +0 -0
  172. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/observability/tracing.py +0 -0
  173. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/profiling/__init__.py +0 -0
  174. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/py.typed +0 -0
  175. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/runtime/__init__.py +0 -0
  176. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/runtime/eventbridge_readiness.py +0 -0
  177. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/runtime/lwa.py +0 -0
  178. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/runtime/serverless.py +0 -0
  179. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/serverless/__init__.py +0 -0
  180. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/serverless/deferred_invoke.py +0 -0
  181. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/serverless/factory.py +0 -0
  182. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/serverless/tasks/__init__.py +0 -0
  183. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/serverless/tasks/aws_lambda.py +0 -0
  184. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/serverless/tasks/aws_sqs.py +0 -0
  185. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/serverless/tasks/base.py +0 -0
  186. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/serverless/tasks/stub.py +0 -0
  187. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/serverless/tasks/sync.py +0 -0
  188. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/storage/__init__.py +0 -0
  189. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/storage/exceptions.py +0 -0
  190. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/storage/interfaces/__init__.py +0 -0
  191. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/storage/interfaces/base.py +0 -0
  192. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/storage/internal_markers.py +0 -0
  193. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/storage/managers/__init__.py +0 -0
  194. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/storage/managers/proxy.py +0 -0
  195. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/storage/models.py +0 -0
  196. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/storage/security/__init__.py +0 -0
  197. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/storage/security/path_sanitizer.py +0 -0
  198. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/storage/security/validator.py +0 -0
  199. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/testing/__init__.py +0 -0
  200. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/utils/__init__.py +0 -0
  201. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/utils/context.py +0 -0
  202. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/utils/decorators.py +0 -0
  203. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/utils/deprecation.py +0 -0
  204. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/utils/factory.py +0 -0
  205. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/utils/normalization.py +0 -0
  206. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/utils/retry.py +0 -0
  207. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/utils/serialization.py +0 -0
  208. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/utils/stability.py +0 -0
  209. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/utils/types.py +0 -0
  210. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial/utils/validation.py +0 -0
  211. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial.egg-info/dependency_links.txt +0 -0
  212. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial.egg-info/entry_points.txt +0 -0
  213. {jvspatial-0.1.0 → jvspatial-0.2.0}/jvspatial.egg-info/top_level.txt +0 -0
  214. {jvspatial-0.1.0 → jvspatial-0.2.0}/setup.cfg +0 -0
  215. {jvspatial-0.1.0 → jvspatial-0.2.0}/tests/test_config.py +0 -0
  216. {jvspatial-0.1.0 → jvspatial-0.2.0}/tests/test_create_task.py +0 -0
  217. {jvspatial-0.1.0 → jvspatial-0.2.0}/tests/test_env.py +0 -0
  218. {jvspatial-0.1.0 → jvspatial-0.2.0}/tests/test_env_adapter_eventbridge_validation.py +0 -0
  219. {jvspatial-0.1.0 → jvspatial-0.2.0}/tests/test_env_adapter_postgres.py +0 -0
  220. {jvspatial-0.1.0 → jvspatial-0.2.0}/tests/test_node_operations.py +0 -0
  221. {jvspatial-0.1.0 → jvspatial-0.2.0}/tests/test_normalize_optional_secret.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jvspatial
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: An asynchronous object-spatial Python library for persistence and business logic application layers.
5
5
  Author-email: "TrueSelph Inc." <adminh@trueselph.com>
6
6
  License: MIT
@@ -29,7 +29,7 @@ Requires-Dist: starlette>=0.46.0
29
29
  Requires-Dist: uvicorn<1.0.0,>=0.23.0
30
30
  Requires-Dist: python-multipart>=0.0.6
31
31
  Requires-Dist: motor>=3.0.0
32
- Requires-Dist: pymongo>=4.0.0
32
+ Requires-Dist: pymongo>=4.13.0
33
33
  Requires-Dist: PyJWT>=2.0.0
34
34
  Requires-Dist: cryptography>=42.0.0
35
35
  Requires-Dist: authlib>=1.3
@@ -60,6 +60,7 @@ Requires-Dist: pytest-asyncio>=0.21.0; extra == "test"
60
60
  Requires-Dist: pytest-cov>=4.0.0; extra == "test"
61
61
  Requires-Dist: pytest-benchmark>=4.0.0; extra == "test"
62
62
  Requires-Dist: httpx>=0.24.0; extra == "test"
63
+ Requires-Dist: python-magic>=0.4.27; extra == "test"
63
64
  Provides-Extra: scheduler
64
65
  Requires-Dist: psutil>=5.9.0; extra == "scheduler"
65
66
  Requires-Dist: python-dotenv>=1.0.0; extra == "scheduler"
@@ -65,6 +65,19 @@ from .core.context import GraphContext, TransactionUnavailable, graph_transactio
65
65
 
66
66
  # Unified entity system
67
67
  from .core.entities import Edge, Node, Object, Root, Walker
68
+ from .core.graph_context_config import (
69
+ ProjectionContextConfig,
70
+ ProjectionEvidence,
71
+ ProjectionField,
72
+ ProjectionRelation,
73
+ )
74
+ from .core.graph_projection import (
75
+ GraphProjection,
76
+ ProjectionAccess,
77
+ ProjectionRequest,
78
+ ProjectionScope,
79
+ ProjectionSemantics,
80
+ )
68
81
 
69
82
  # Mixins
70
83
  from .core.mixins import (
@@ -129,6 +142,15 @@ __all__ = [
129
142
  "Walker",
130
143
  "Root",
131
144
  "GraphContext",
145
+ "GraphProjection",
146
+ "ProjectionRequest",
147
+ "ProjectionScope",
148
+ "ProjectionAccess",
149
+ "ProjectionContextConfig",
150
+ "ProjectionEvidence",
151
+ "ProjectionField",
152
+ "ProjectionRelation",
153
+ "ProjectionSemantics",
132
154
  "graph_transaction",
133
155
  "TransactionUnavailable",
134
156
  # Mixins
@@ -37,7 +37,20 @@ from .graph import (
37
37
  generate_graph_dot,
38
38
  generate_graph_mermaid,
39
39
  )
40
+ from .graph_context_config import (
41
+ ProjectionContextConfig,
42
+ ProjectionEvidence,
43
+ ProjectionField,
44
+ ProjectionRelation,
45
+ )
40
46
  from .graph_expansion import expand_node, subgraph_bfs
47
+ from .graph_projection import (
48
+ GraphProjection,
49
+ ProjectionAccess,
50
+ ProjectionRequest,
51
+ ProjectionScope,
52
+ ProjectionSemantics,
53
+ )
41
54
  from .mixins import (
42
55
  DeferredSaveMixin,
43
56
  deferred_saves_globally_allowed,
@@ -78,6 +91,15 @@ __all__ = [
78
91
  "subgraph_bfs",
79
92
  # Context (advanced usage)
80
93
  "GraphContext",
94
+ "GraphProjection",
95
+ "ProjectionRequest",
96
+ "ProjectionScope",
97
+ "ProjectionAccess",
98
+ "ProjectionContextConfig",
99
+ "ProjectionEvidence",
100
+ "ProjectionField",
101
+ "ProjectionRelation",
102
+ "ProjectionSemantics",
81
103
  "get_default_context",
82
104
  "set_default_context",
83
105
  "reset_default_context",
@@ -84,7 +84,9 @@ def attribute(
84
84
  private: If True, creates a Pydantic private attribute (underscore field)
85
85
  indexed: If True, create a single-field index on this field (maps to context.field_name)
86
86
  index_unique: If True, create a unique index (requires indexed=True)
87
- index_direction: Index direction for sorting (1=ascending, -1=descending, default=1)
87
+ index_direction: Index direction for sorting (1=ascending, -1=descending,
88
+ default=1). SQL sort indexes preserve direction; single-key MongoDB
89
+ and DynamoDB indexes reuse their canonical shape/name.
88
90
  index_partial_filter_expression: MongoDB partialFilterExpression dict to scope the
89
91
  index to a subset of documents. Field paths must use the full
90
92
  ``context.<field>`` DB path. Use this with ``index_unique=True`` to avoid
@@ -46,6 +46,14 @@ def end_request_identity_map(
46
46
 
47
47
  if TYPE_CHECKING:
48
48
  from .entities import Object
49
+ from .graph_projection import (
50
+ GraphProjection,
51
+ ProjectionAccess,
52
+ ProjectionContextConfig,
53
+ ProjectionRequest,
54
+ ProjectionScope,
55
+ ProjectionSemantics,
56
+ )
49
57
 
50
58
  T = TypeVar("T", bound="Object")
51
59
 
@@ -56,17 +64,55 @@ logger = logging.getLogger(__name__)
56
64
  _ensured_indexes: WeakKeyDictionary[Any, Set[str]] = WeakKeyDictionary()
57
65
 
58
66
 
67
+ class _SampleWindow(list):
68
+ """List-compatible bounded diagnostic samples (statistics cover this window)."""
69
+
70
+ def __init__(self, limit: int):
71
+ super().__init__()
72
+ self.limit = limit
73
+ self.total_count = 0
74
+ self.total_duration = 0.0
75
+ self.total_bytes_read = 0
76
+ self.total_bytes_written = 0
77
+
78
+ def append(self, value):
79
+ self.total_count += 1
80
+ self.total_duration += value.get("duration", 0)
81
+ self.total_bytes_read += value.get("bytes_read") or 0
82
+ self.total_bytes_written += value.get("bytes_written") or 0
83
+ super().append(value)
84
+ if len(self) > self.limit:
85
+ del self[: len(self) - self.limit]
86
+
87
+ def clear(self):
88
+ super().clear()
89
+ self.total_count = 0
90
+ self.total_duration = 0.0
91
+ self.total_bytes_read = self.total_bytes_written = 0
92
+
93
+ def extend(self, values):
94
+ for value in values:
95
+ self.append(value)
96
+
97
+
59
98
  # Simple performance monitor for tracking operations
60
99
  class PerformanceMonitor:
61
100
  """Enhanced performance monitoring for database operations and general operations."""
62
101
 
63
- def __init__(self) -> None:
64
- self.db_operations: List[Dict[str, Any]] = []
65
- self.hook_executions: List[Dict[str, Any]] = []
66
- self.db_errors: List[Dict[str, Any]] = []
67
- self.general_operations: List[Dict[str, Any]] = []
68
- self.general_errors: List[Dict[str, Any]] = []
69
- self.io_operations: List[Dict[str, Any]] = [] # Track file I/O operations
102
+ def __init__(self, sample_limit: int = 1000) -> None:
103
+ if (
104
+ isinstance(sample_limit, bool)
105
+ or not isinstance(sample_limit, int)
106
+ or sample_limit < 1
107
+ ):
108
+ raise ValueError("sample_limit must be a positive integer")
109
+ self.sample_limit = sample_limit
110
+ self.db_operations: _SampleWindow = _SampleWindow(sample_limit)
111
+ self.hook_executions: _SampleWindow = _SampleWindow(sample_limit)
112
+ self.db_errors: _SampleWindow = _SampleWindow(sample_limit)
113
+ self.general_operations: _SampleWindow = _SampleWindow(sample_limit)
114
+ self.general_errors: _SampleWindow = _SampleWindow(sample_limit)
115
+ self.io_operations: _SampleWindow = _SampleWindow(sample_limit)
70
116
  self.cache_stats: Dict[str, int] = {
71
117
  "hits": 0,
72
118
  "misses": 0,
@@ -206,10 +252,10 @@ class PerformanceMonitor:
206
252
  return self.cache_stats["hits"] / total
207
253
 
208
254
  async def get_stats(self) -> Dict[str, Any]:
209
- """Get comprehensive performance statistics."""
210
- total_db_ops = len(self.db_operations)
211
- total_general_ops = len(self.general_operations)
212
- total_io_ops = len(self.io_operations)
255
+ """Lifetime counts/means and percentiles over retained sample windows."""
256
+ total_db_ops = self.db_operations.total_count
257
+ total_general_ops = self.general_operations.total_count
258
+ total_io_ops = self.io_operations.total_count
213
259
  total_ops = total_db_ops + total_general_ops + total_io_ops
214
260
 
215
261
  if total_ops == 0:
@@ -224,7 +270,7 @@ class PerformanceMonitor:
224
270
  db_stats = {}
225
271
  if total_db_ops > 0:
226
272
  durations = [op["duration"] for op in self.db_operations]
227
- avg_db_duration = sum(durations) / total_db_ops
273
+ avg_db_duration = self.db_operations.total_duration / total_db_ops
228
274
  # Calculate percentiles for database operations
229
275
  sorted_durations = sorted(durations)
230
276
  p50_idx = int(len(sorted_durations) * 0.5)
@@ -242,35 +288,32 @@ class PerformanceMonitor:
242
288
  "p99_db_duration": (
243
289
  sorted_durations[p99_idx] if sorted_durations else 0.0
244
290
  ),
245
- "db_errors": len(self.db_errors),
291
+ "db_errors": self.db_errors.total_count,
246
292
  }
247
293
 
248
294
  # Calculate general operation statistics
249
295
  general_stats = {}
250
296
  if total_general_ops > 0:
251
297
  avg_general_duration = (
252
- sum(op["duration"] for op in self.general_operations)
253
- / total_general_ops
298
+ self.general_operations.total_duration / total_general_ops
254
299
  )
255
300
  general_stats = {
256
301
  "general_operations": total_general_ops,
257
302
  "avg_general_duration": avg_general_duration,
258
- "general_errors": len(self.general_errors),
303
+ "general_errors": self.general_errors.total_count,
259
304
  }
260
305
 
261
306
  # Calculate I/O operation statistics
262
307
  io_stats = {}
263
308
  if total_io_ops > 0:
264
309
  durations = [op["duration"] for op in self.io_operations]
265
- avg_io_duration = sum(durations) / total_io_ops
310
+ avg_io_duration = self.io_operations.total_duration / total_io_ops
266
311
  sorted_io_durations = sorted(durations)
267
312
  p50_idx = int(len(sorted_io_durations) * 0.5)
268
313
  p95_idx = int(len(sorted_io_durations) * 0.95)
269
314
  p99_idx = int(len(sorted_io_durations) * 0.99)
270
- total_bytes_read = sum(op.get("bytes_read", 0) for op in self.io_operations)
271
- total_bytes_written = sum(
272
- op.get("bytes_written", 0) for op in self.io_operations
273
- )
315
+ total_bytes_read = self.io_operations.total_bytes_read
316
+ total_bytes_written = self.io_operations.total_bytes_written
274
317
  io_stats = {
275
318
  "io_operations": total_io_ops,
276
319
  "avg_io_duration": avg_io_duration,
@@ -297,7 +340,7 @@ class PerformanceMonitor:
297
340
 
298
341
  return {
299
342
  "total_operations": total_ops,
300
- "hook_executions": len(self.hook_executions),
343
+ "hook_executions": self.hook_executions.total_count,
301
344
  **db_stats,
302
345
  **general_stats,
303
346
  **io_stats,
@@ -658,7 +701,7 @@ class GraphContext:
658
701
  # Use the overridden create() method
659
702
  entity = await entity_class.create(**kwargs)
660
703
  entity._graph_context = self
661
- return entity
704
+ return cast(T, entity)
662
705
 
663
706
  # Default behavior: instantiate and save
664
707
  entity = entity_class(**kwargs)
@@ -724,6 +767,7 @@ class GraphContext:
724
767
  from .entities.object import Object as _Object
725
768
 
726
769
  # Walk up to the appropriate hierarchy root
770
+ hierarchy_root: Type[_Object]
727
771
  if issubclass(entity_class, _Node):
728
772
  hierarchy_root = _Node
729
773
  elif issubclass(entity_class, _Object):
@@ -755,15 +799,8 @@ class GraphContext:
755
799
  if self._cache:
756
800
  await self._cache.delete(entity_id)
757
801
 
758
- async def save(self, entity):
759
- """Save an entity to the database.
760
-
761
- Args:
762
- entity: Entity instance to save
763
-
764
- Returns:
765
- The saved entity instance
766
- """
802
+ async def _prepare_save(self, entity):
803
+ """Apply the same export/normalization/collection rules to every save."""
767
804
  # Export entity - Node, Edge, and Walker return nested format
768
805
  if hasattr(entity, "type_code"):
769
806
  type_code = getattr(entity, "type_code", "")
@@ -835,17 +872,17 @@ class GraphContext:
835
872
  else:
836
873
  collection = type_code.lower()
837
874
 
838
- db = self.database
839
875
  is_node = (
840
876
  hasattr(entity, "type_code") and getattr(entity, "type_code", "") == "n"
841
877
  )
842
-
843
- # Adjacency lives in the edge collection. Never write a legacy ``edges``
844
- # array onto node rows (a leftover in-memory copy must not re-persist it).
845
878
  if is_node:
846
879
  record.pop("edges", None)
880
+ return collection, record
847
881
 
848
- await db.save(collection, record)
882
+ async def save(self, entity):
883
+ """Save one entity with shared preparation and cache invalidation."""
884
+ collection, record = await self._prepare_save(entity)
885
+ await self.database.save(collection, record)
849
886
  await self._add_to_cache(entity.id, entity)
850
887
  return entity
851
888
 
@@ -963,6 +1000,31 @@ class GraphContext:
963
1000
  self, format=format, output_file=output_file, **kwargs
964
1001
  )
965
1002
 
1003
+ async def project_graph(
1004
+ self,
1005
+ request: "ProjectionRequest",
1006
+ *,
1007
+ scope: "ProjectionScope",
1008
+ access: Optional["ProjectionAccess"] = None,
1009
+ semantics: Optional["ProjectionSemantics"] = None,
1010
+ context_config: Optional["ProjectionContextConfig"] = None,
1011
+ ) -> "GraphProjection":
1012
+ """Capture scoped relationship context for language-model inference.
1013
+
1014
+ See ``ProjectionRequest`` for view, field, traversal, and budget controls.
1015
+ The application supplies explicit scope and optional current access rules.
1016
+ """
1017
+ from .graph_projection import project_graph
1018
+
1019
+ return await project_graph(
1020
+ self,
1021
+ request,
1022
+ scope=scope,
1023
+ access=access,
1024
+ semantics=semantics,
1025
+ context_config=context_config,
1026
+ )
1027
+
966
1028
  async def expand_node(
967
1029
  self,
968
1030
  node_id: str,
@@ -972,6 +1034,7 @@ class GraphContext:
972
1034
  cursor: int = 0,
973
1035
  after: Optional[str] = None,
974
1036
  detail_level: str = "full",
1037
+ include_counts: bool = True,
975
1038
  ) -> Dict[str, Any]:
976
1039
  """Return a page of incident edges and neighbor summaries for progressive UIs.
977
1040
 
@@ -989,6 +1052,7 @@ class GraphContext:
989
1052
  cursor=cursor,
990
1053
  after=after,
991
1054
  detail_level=detail_level, # type: ignore[arg-type]
1055
+ include_counts=include_counts,
992
1056
  )
993
1057
 
994
1058
  async def subgraph_bfs(
@@ -999,6 +1063,8 @@ class GraphContext:
999
1063
  max_nodes: int = 100,
1000
1064
  max_edges_per_node: int = 200,
1001
1065
  detail_level: str = "full",
1066
+ include_counts: bool = True,
1067
+ priority_node_prefixes: Optional[List[str]] = None,
1002
1068
  ) -> Dict[str, Any]:
1003
1069
  """Return a bounded BFS subgraph from ``root_id``.
1004
1070
 
@@ -1015,6 +1081,8 @@ class GraphContext:
1015
1081
  max_nodes=max_nodes,
1016
1082
  max_edges_per_node=max_edges_per_node,
1017
1083
  detail_level=detail_level, # type: ignore[arg-type]
1084
+ include_counts=include_counts,
1085
+ priority_node_prefixes=priority_node_prefixes,
1018
1086
  )
1019
1087
 
1020
1088
  def _get_collection_name(self, type_code: str) -> str:
@@ -1244,7 +1312,11 @@ class GraphContext:
1244
1312
  page_limit = max(1, int(limit))
1245
1313
  sort_fields = keyset_sort_fields(sort)
1246
1314
  final_query: Dict[str, Any] = dict(query or {})
1247
- after_cursor = keyset_filter(sort_fields, decode_keyset_cursor(after))
1315
+ after_cursor = keyset_filter(
1316
+ sort_fields,
1317
+ decode_keyset_cursor(after),
1318
+ nulls_first=getattr(self.database, "_keyset_nulls_first", False),
1319
+ )
1248
1320
  if after_cursor is not None:
1249
1321
  final_query = (
1250
1322
  {"$and": [final_query, after_cursor]} if final_query else after_cursor
@@ -1379,22 +1451,23 @@ class GraphContext:
1379
1451
  if not neighbor_ids:
1380
1452
  return out
1381
1453
 
1382
- node_docs = await self.database.find(
1383
- self._get_collection_name(self._get_entity_type_code(Node)),
1384
- {"id": {"$in": list(neighbor_ids)}},
1385
- )
1454
+ node_collection = self._get_collection_name(self._get_entity_type_code(Node))
1455
+ if node_entities or node_filter:
1456
+ query = {"id": {"$in": list(neighbor_ids)}}
1457
+ if node_entities:
1458
+ query["entity"] = {"$in": node_entities}
1459
+ query.update({f"context.{k}": v for k, v in (node_filter or {}).items()})
1460
+ node_docs = await self.database.find(node_collection, query)
1461
+ else:
1462
+ node_docs = list(
1463
+ (
1464
+ await self.database.find_many(node_collection, list(neighbor_ids))
1465
+ ).values()
1466
+ )
1386
1467
  node_by_id: Dict[str, Any] = {}
1387
1468
  for doc in node_docs:
1388
1469
  if node_entities and doc.get("entity") not in node_entities:
1389
1470
  continue
1390
- if node_filter:
1391
- fail = False
1392
- for k, v in node_filter.items():
1393
- if doc.get("context", {}).get(k) != v:
1394
- fail = True
1395
- break
1396
- if fail:
1397
- continue
1398
1471
  obj = await self._deserialize_entity(Node, doc)
1399
1472
  if obj is not None:
1400
1473
  node_by_id[doc["id"]] = obj
@@ -1472,6 +1545,7 @@ class GraphContext:
1472
1545
  # Postgres-only; other adapters receive the plain definition, and
1473
1546
  # full-text definitions are skipped where ``$text`` runs in memory.
1474
1547
  is_postgres = self._is_postgres(self.database)
1548
+ is_mongodb = self._is_mongodb(self.database)
1475
1549
  scope_keys = ("per_class", "entity_leading", "partial_by_entity", "fulltext")
1476
1550
 
1477
1551
  def _scoped(index_def: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
@@ -1497,9 +1571,19 @@ class GraphContext:
1497
1571
  for k, v in index_def.items()
1498
1572
  if k not in ("field", "unique", "direction", *scope_keys)
1499
1573
  }
1574
+ # Preserve existing ascending names; list form carries a
1575
+ # descending declaration to adapters that support it.
1576
+ # Mongo single-key B-trees serve either direction; keep
1577
+ # annotation-created indexes in their canonical shape.
1578
+ direction = index_def.get("direction", 1)
1579
+ field_spec: Union[str, List[Tuple[str, int]]] = (
1580
+ index_def["field"]
1581
+ if direction == 1 or is_mongodb
1582
+ else [(index_def["field"], direction)]
1583
+ )
1500
1584
  await self.database.create_index(
1501
1585
  collection,
1502
- index_def["field"],
1586
+ field_spec,
1503
1587
  unique=index_def.get("unique", False),
1504
1588
  **_scoped(index_def, extra),
1505
1589
  )
@@ -1747,104 +1831,51 @@ class GraphContext:
1747
1831
 
1748
1832
  # Batch operations for improved performance
1749
1833
  async def save_batch(self, entities: List[Any]) -> List[Any]:
1750
- """Save multiple entities in best-effort batch (not an ACID transaction).
1751
-
1752
- Persists each entity via the database adapter's ``save`` (or ``batch_write``
1753
- where supported). There is no cross-collection atomicity unless the
1754
- underlying :meth:`Database.begin_transaction` / ``commit_transaction``
1755
- API is used and wired through callers separately.
1834
+ """Save per-collection batches, returning only confirmed successes.
1756
1835
 
1757
- Args:
1758
- entities: List of entity instances to save
1759
-
1760
- Returns:
1761
- List of saved entity instances
1836
+ Uses the same preparation as context.save(), including normalization
1837
+ and stripping legacy adjacency. This context-level operation does not
1838
+ invoke overridden entity.save() methods, matching individual context
1839
+ saves. Batches are best-effort, not cross-collection transactions.
1762
1840
  """
1763
- if not entities:
1764
- return []
1765
-
1766
- # Group entities by type for efficient batch operations
1767
- entities_by_type: Dict[str, List[Any]] = {}
1841
+ grouped: Dict[str, List[Tuple[Any, Dict[str, Any]]]] = {}
1768
1842
  for entity in entities:
1769
- collection = self._get_collection_name(entity.type_code)
1770
- if collection not in entities_by_type:
1771
- entities_by_type[collection] = []
1772
- entities_by_type[collection].append(entity)
1773
-
1774
- saved_entities = []
1775
-
1776
- # Process each type group
1777
- for collection, type_entities in entities_by_type.items():
1778
- is_node_collection = collection == "node"
1779
- if is_node_collection:
1780
- for entity in type_entities:
1781
- try:
1782
- saved = await self.save(entity)
1783
- saved_entities.append(saved)
1784
- except Exception as e:
1785
- logger.error(
1786
- "Failed to save entity %s: %s",
1787
- getattr(entity, "id", "unknown"),
1788
- e,
1789
- exc_info=True,
1790
- )
1791
- continue
1792
-
1793
- # Export all entities of this type
1794
- records = []
1795
- for entity in type_entities:
1796
- record = await entity.export()
1797
- if getattr(entity, "type_code", "") == "n":
1798
- record.pop("edges", None)
1799
- records.append(record)
1800
-
1801
- # Save all records of this type
1802
- db = self.database
1803
-
1804
- # Use batch_write if available (e.g., DynamoDB), otherwise fall back to sequential saves
1805
- if hasattr(db, "batch_write"):
1843
+ collection, record = await self._prepare_save(entity)
1844
+ grouped.setdefault(collection, []).append((entity, record))
1845
+ saved = []
1846
+ for collection, items in grouped.items():
1847
+ for offset in range(0, len(items), 500):
1848
+ chunk = items[offset : offset + 500]
1849
+ # Every attempted identity is stale if the batch fails or is
1850
+ # cancelled, including when a backend outcome is uncertain.
1806
1851
  try:
1807
- # Use batch write for efficiency
1808
- await db.batch_write(collection, records)
1809
- # Update cache with latest versions
1810
- for i, record in enumerate(records):
1811
- await self._add_to_cache(record["id"], type_entities[i])
1812
- saved_entities.append(type_entities[i])
1813
- except Exception as e:
1814
- # If batch write fails, fall back to sequential saves
1815
- logger.warning(
1816
- f"Batch write failed for collection '{collection}', falling back to sequential saves: {e}"
1817
- )
1818
- for i, record in enumerate(records):
1819
- try:
1820
- await db.save(collection, record)
1821
- # Update cache with latest version
1822
- await self._add_to_cache(record["id"], type_entities[i])
1823
- saved_entities.append(type_entities[i])
1824
- except Exception as save_error:
1825
- # Log error but continue with other entities
1826
- logger.error(
1827
- f"Failed to save entity {type_entities[i].get('id', 'unknown')}: {save_error}",
1828
- exc_info=True,
1829
- )
1830
- continue
1831
- else:
1832
- # Fall back to sequential saves for databases without batch support
1833
- for i, record in enumerate(records):
1834
- try:
1835
- await db.save(collection, record)
1836
- # Update cache with latest version
1837
- await self._add_to_cache(record["id"], type_entities[i])
1838
- saved_entities.append(type_entities[i])
1839
- except Exception as e:
1840
- # Log error but continue with other entities
1841
- logger.error(
1842
- f"Failed to save entity {type_entities[i].get('id', 'unknown')}: {e}",
1843
- exc_info=True,
1844
- )
1845
- continue
1846
-
1847
- return saved_entities
1852
+ bulk = getattr(self.database, "bulk_save_detailed", None)
1853
+ if callable(bulk):
1854
+ result = await bulk(collection, [record for _, record in chunk])
1855
+ else:
1856
+ from jvspatial.db.database import BulkSaveResult
1857
+
1858
+ for _, record in chunk:
1859
+ await self.database.save(collection, record)
1860
+ result = BulkSaveResult(len(chunk), len(chunk), [])
1861
+ except BaseException:
1862
+ for entity, _ in chunk:
1863
+ await self._evict_from_cache(entity.id)
1864
+ raise
1865
+ failed = set(result.failed_ids)
1866
+ if result.saved != len(chunk) and not failed:
1867
+ # Count-only custom adapters cannot identify successful IDs.
1868
+ for entity, _ in chunk:
1869
+ await self._evict_from_cache(entity.id)
1870
+ continue
1871
+ for entity, _ in chunk:
1872
+ if str(entity.id) in failed:
1873
+ await self._evict_from_cache(entity.id)
1874
+ else:
1875
+ await self._add_to_cache(entity.id, entity)
1876
+ saved.append(entity)
1877
+ confirmed = {id(entity) for entity in saved}
1878
+ return [entity for entity in entities if id(entity) in confirmed]
1848
1879
 
1849
1880
  async def get_batch(self, entity_class: Type[T], ids: List[str]) -> List[T]:
1850
1881
  """Retrieve multiple entities by ID.
@@ -692,7 +692,11 @@ class Node(Object):
692
692
  edge_entities, edge_query, node_entities, node_query = self._neighbor_spec(
693
693
  node, edge, kwargs
694
694
  )
695
- after = keyset_filter(sort_fields, decode_keyset_cursor(cursor))
695
+ after = keyset_filter(
696
+ sort_fields,
697
+ decode_keyset_cursor(cursor),
698
+ nulls_first=getattr(context.database, "_keyset_nulls_first", False),
699
+ )
696
700
  if after is not None:
697
701
  node_query = after if node_query is None else {"$and": [node_query, after]}
698
702
  found = await self._neighbors(
@@ -1122,9 +1126,9 @@ class Node(Object):
1122
1126
  except re.error:
1123
1127
  return False
1124
1128
  elif operator == "$exists":
1125
- # This is handled at the property level, not here
1126
- # If we reach this point, the property exists
1127
- if not criterion: # $exists: False means property shouldn't exist
1129
+ # Match the in-memory query contract: null and missing are
1130
+ # absent; falsy non-null values remain present.
1131
+ if bool(criterion) != (value is not None):
1128
1132
  return False
1129
1133
  else:
1130
1134
  # Unknown operator - ignore