openframe-core 3.4.0__tar.gz → 3.6.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 (155) hide show
  1. {openframe_core-3.4.0 → openframe_core-3.6.0}/PKG-INFO +1 -1
  2. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/ports/__init__.py +10 -2
  3. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/ports/outbound/__init__.py +24 -4
  4. openframe_core-3.6.0/openframe/core/ports/outbound/repository.py +372 -0
  5. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/testing/__init__.py +8 -0
  6. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/testing/contracts/__init__.py +10 -0
  7. openframe_core-3.6.0/openframe/core/testing/contracts/graph_store.py +143 -0
  8. openframe_core-3.6.0/openframe/core/testing/contracts/vector_store.py +148 -0
  9. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/testing/fakes/__init__.py +4 -0
  10. openframe_core-3.6.0/openframe/core/testing/fakes/graph_store.py +120 -0
  11. openframe_core-3.6.0/openframe/core/testing/fakes/vector_store.py +128 -0
  12. {openframe_core-3.4.0 → openframe_core-3.6.0}/pyproject.toml +1 -1
  13. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_testing_fakes.py +79 -0
  14. openframe_core-3.4.0/openframe/core/ports/outbound/repository.py +0 -158
  15. {openframe_core-3.4.0 → openframe_core-3.6.0}/.github/CHANGELOG.md +0 -0
  16. {openframe_core-3.4.0 → openframe_core-3.6.0}/.github/requirements/docs.txt +0 -0
  17. {openframe_core-3.4.0 → openframe_core-3.6.0}/.github/requirements/test.txt +0 -0
  18. {openframe_core-3.4.0 → openframe_core-3.6.0}/.github/workflows/app-test.yml +0 -0
  19. {openframe_core-3.4.0 → openframe_core-3.6.0}/.github/workflows/auto-docs.yml +0 -0
  20. {openframe_core-3.4.0 → openframe_core-3.6.0}/.github/workflows/python-build.yml +0 -0
  21. {openframe_core-3.4.0 → openframe_core-3.6.0}/.gitignore +0 -0
  22. {openframe_core-3.4.0 → openframe_core-3.6.0}/.vscode/settings.json +0 -0
  23. {openframe_core-3.4.0 → openframe_core-3.6.0}/LICENSE +0 -0
  24. {openframe_core-3.4.0 → openframe_core-3.6.0}/README.md +0 -0
  25. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/assets/images/banner.png +0 -0
  26. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/assets/images/favicon.png +0 -0
  27. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/assets/images/logo-dark.png +0 -0
  28. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/assets/images/logo-light.png +0 -0
  29. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/assets/javascripts/extra.js +0 -0
  30. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/assets/stylesheets/extra.css +0 -0
  31. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/code/cicd/pipeline.md +0 -0
  32. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/code/getting-started/index.md +0 -0
  33. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/code/modules/config.md +0 -0
  34. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/code/modules/exceptions.md +0 -0
  35. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/code/modules/health.md +0 -0
  36. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/code/modules/inbound.md +0 -0
  37. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/code/modules/index.md +0 -0
  38. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/code/modules/middleware.md +0 -0
  39. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/code/modules/plugins.md +0 -0
  40. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/code/modules/ports.md +0 -0
  41. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/code/modules/runtime.md +0 -0
  42. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/code/modules/telemetry.md +0 -0
  43. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/code/modules/testing.md +0 -0
  44. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/code/modules/tracing.md +0 -0
  45. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/developer-guide/debugging.md +0 -0
  46. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/developer-guide/explore.md +0 -0
  47. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/developer-guide/first-change.md +0 -0
  48. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/developer-guide/how-it-works.md +0 -0
  49. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/developer-guide/local-dev.md +0 -0
  50. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/developer-guide/quick-start/index.md +0 -0
  51. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/index.md +0 -0
  52. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/adrs/adr-001-namespace.md +0 -0
  53. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/adrs/adr-002-hexagonal.md +0 -0
  54. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/adrs/adr-003-async.md +0 -0
  55. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/adrs/adr-004-pydantic.md +0 -0
  56. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/adrs/adr-005-middleware.md +0 -0
  57. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/adrs/adr-006-unified-port-lifecycle.md +0 -0
  58. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/adrs/adr-007-schema-contracts.md +0 -0
  59. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/adrs/index.md +0 -0
  60. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/capability-taxonomy.md +0 -0
  61. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/component-lifecycle.md +0 -0
  62. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/data-flows.md +0 -0
  63. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/design-decisions.md +0 -0
  64. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/error-taxonomy.md +0 -0
  65. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/package-journey/adapter-lifecycle.md +0 -0
  66. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/package-journey/error-propagation.md +0 -0
  67. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/package-journey/index.md +0 -0
  68. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/package-journey/template-wiring.md +0 -0
  69. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/package-journey/tracing-flow.md +0 -0
  70. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/architecture/system-architecture.md +0 -0
  71. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/deployment/cicd.md +0 -0
  72. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/deployment/environments.md +0 -0
  73. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/deployment/secrets-and-config.md +0 -0
  74. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/infrastructure/networking.md +0 -0
  75. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/infrastructure/platform.md +0 -0
  76. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/infrastructure/storage.md +0 -0
  77. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/operations/index.md +0 -0
  78. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/operations/monitoring.md +0 -0
  79. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/operations/runbooks/adapter-connection-failure.md +0 -0
  80. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/operations/runbooks/modal-cold-start-spike.md +0 -0
  81. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/operations/runbooks/postgres-pool-exhausted.md +0 -0
  82. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/operations/runbooks/queue-consumer-stuck.md +0 -0
  83. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/operations/runbooks/redis-connection-lost.md +0 -0
  84. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/operations/runbooks/telemetry-export-failing.md +0 -0
  85. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/overview/glossary.md +0 -0
  86. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/overview/roadmap.md +0 -0
  87. {openframe_core-3.4.0 → openframe_core-3.6.0}/docs/technical/overview/system-overview.md +0 -0
  88. {openframe_core-3.4.0 → openframe_core-3.6.0}/mkdocs.yml +0 -0
  89. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/__init__.py +0 -0
  90. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/__init__.py +0 -0
  91. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/config/__init__.py +0 -0
  92. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/config/settings.py +0 -0
  93. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/exceptions/__init__.py +0 -0
  94. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/exceptions/adapter.py +0 -0
  95. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/exceptions/base.py +0 -0
  96. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/exceptions/codes.py +0 -0
  97. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/exceptions/plugin.py +0 -0
  98. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/inbound/__init__.py +0 -0
  99. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/inbound/command.py +0 -0
  100. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/inbound/context.py +0 -0
  101. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/inbound/query.py +0 -0
  102. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/inbound/usecase.py +0 -0
  103. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/middleware/__init__.py +0 -0
  104. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/middleware/telemetry.py +0 -0
  105. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/middleware/types.py +0 -0
  106. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/plugins/__init__.py +0 -0
  107. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/plugins/registry.py +0 -0
  108. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/ports/capability.py +0 -0
  109. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/ports/context.py +0 -0
  110. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/ports/health.py +0 -0
  111. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/ports/identity.py +0 -0
  112. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/ports/lifecycle.py +0 -0
  113. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/ports/outbound/consumer.py +0 -0
  114. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/ports/outbound/producer.py +0 -0
  115. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/ports/port.py +0 -0
  116. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/resilience/__init__.py +0 -0
  117. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/resilience/circuit_breaker.py +0 -0
  118. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/runtime/__init__.py +0 -0
  119. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/runtime/bootstrap.py +0 -0
  120. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/schemas/__init__.py +0 -0
  121. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/telemetry/__init__.py +0 -0
  122. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/telemetry/setup.py +0 -0
  123. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/testing/contracts/consumer.py +0 -0
  124. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/testing/contracts/inbound.py +0 -0
  125. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/testing/contracts/lifecycle.py +0 -0
  126. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/testing/contracts/port.py +0 -0
  127. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/testing/contracts/producer.py +0 -0
  128. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/testing/contracts/repository.py +0 -0
  129. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/testing/fakes/consumer.py +0 -0
  130. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/testing/fakes/inbound.py +0 -0
  131. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/testing/fakes/producer.py +0 -0
  132. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/testing/fakes/repository.py +0 -0
  133. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/testing/fixtures.py +0 -0
  134. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/tracing/__init__.py +0 -0
  135. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/tracing/propagation.py +0 -0
  136. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/core/tracing/proxy.py +0 -0
  137. {openframe_core-3.4.0 → openframe_core-3.6.0}/openframe/py.typed +0 -0
  138. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/conftest.py +0 -0
  139. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_config.py +0 -0
  140. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_exceptions.py +0 -0
  141. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_inbound.py +0 -0
  142. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_inbound_contracts.py +0 -0
  143. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_middleware.py +0 -0
  144. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_middleware_types.py +0 -0
  145. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_plugins.py +0 -0
  146. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_ports.py +0 -0
  147. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_ports_primitives.py +0 -0
  148. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_record_error.py +0 -0
  149. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_resilience.py +0 -0
  150. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_runtime.py +0 -0
  151. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_schemas.py +0 -0
  152. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_shutdown_telemetry.py +0 -0
  153. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_telemetry_sidecar.py +0 -0
  154. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_tracing.py +0 -0
  155. {openframe_core-3.4.0 → openframe_core-3.6.0}/tests/test_tracing_propagation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: openframe-core
3
- Version: 3.4.0
3
+ Version: 3.6.0
4
4
  Summary: OpenFrame Microservice Suite - platform kernel. Port contracts, testing utilities, plugin registry, and composition root.
5
5
  Project-URL: Homepage, https://github.com/Furious-Meteors/openframe-core
6
6
  Project-URL: Documentation, https://furious-meteors.github.io/openframe-core/
@@ -29,7 +29,7 @@ old ``openframe.core.contracts`` path — update all imports to
29
29
  top of BasePort, defined in ``ports/outbound/`` (see that sub-module's
30
30
  docstring for the intake rule on future capabilities)::
31
31
 
32
- from openframe.core.ports import BaseRepository, BaseProducer, BaseConsumer
32
+ from openframe.core.ports import BaseRepository, BaseProducer, BaseConsumer, BaseVectorStore, BaseGraphStore
33
33
 
34
34
  Runtime isinstance checks::
35
35
 
@@ -43,7 +43,13 @@ from openframe.core.ports.context import PrincipalContext, TenantContext
43
43
  from openframe.core.ports.health import PluginContext, PluginHealth, PluginStatus
44
44
  from openframe.core.ports.identity import Identity
45
45
  from openframe.core.ports.lifecycle import Lifecycle
46
- from openframe.core.ports.outbound import BaseConsumer, BaseProducer, BaseRepository
46
+ from openframe.core.ports.outbound import (
47
+ BaseConsumer,
48
+ BaseGraphStore,
49
+ BaseProducer,
50
+ BaseRepository,
51
+ BaseVectorStore,
52
+ )
47
53
  from openframe.core.ports.port import BasePort
48
54
 
49
55
  __all__ = [
@@ -61,4 +67,6 @@ __all__ = [
61
67
  "BaseRepository",
62
68
  "BaseProducer",
63
69
  "BaseConsumer",
70
+ "BaseVectorStore",
71
+ "BaseGraphStore",
64
72
  ]
@@ -14,6 +14,16 @@ outbound capability:
14
14
  message publishing (``QUEUE`` capability).
15
15
  - :class:`~openframe.core.ports.outbound.consumer.BaseConsumer` —
16
16
  message consumption (``QUEUE`` capability).
17
+ - :class:`~openframe.core.ports.outbound.repository.BaseVectorStore` —
18
+ id-addressable CRUD plus similarity search (``SEARCH`` capability).
19
+ Extends ``BaseRepository`` directly (defined in the same module) rather
20
+ than sitting as a sibling of it — see ``repository.py``'s own
21
+ docstring for why.
22
+ - :class:`~openframe.core.ports.outbound.repository.BaseGraphStore` —
23
+ id-addressable node CRUD plus pattern-matching traversal (``SEARCH``
24
+ capability). Also extends ``BaseRepository`` directly (defined in the
25
+ same module) — see ``repository.py``'s own docstring for why, and for
26
+ why relationships/edges are not given their own port method.
17
27
 
18
28
  Future outbound capabilities (openframe-infra: ``BaseSecretsProvider``,
19
29
  ``BaseObjectStore``, ``BaseFeatureFlagProvider``) will be added here
@@ -25,7 +35,7 @@ Implementations live in openframe-adapters.
25
35
 
26
36
  Usage::
27
37
 
28
- from openframe.core.ports import BaseRepository, BaseProducer, BaseConsumer
38
+ from openframe.core.ports import BaseRepository, BaseProducer, BaseConsumer, BaseVectorStore, BaseGraphStore
29
39
 
30
40
  Or via explicit path if needed::
31
41
 
@@ -35,6 +45,16 @@ from __future__ import annotations
35
45
 
36
46
  from openframe.core.ports.outbound.consumer import BaseConsumer
37
47
  from openframe.core.ports.outbound.producer import BaseProducer
38
- from openframe.core.ports.outbound.repository import BaseRepository
39
-
40
- __all__ = ["BaseRepository", "BaseProducer", "BaseConsumer"]
48
+ from openframe.core.ports.outbound.repository import (
49
+ BaseGraphStore,
50
+ BaseRepository,
51
+ BaseVectorStore,
52
+ )
53
+
54
+ __all__ = [
55
+ "BaseRepository",
56
+ "BaseProducer",
57
+ "BaseConsumer",
58
+ "BaseVectorStore",
59
+ "BaseGraphStore",
60
+ ]
@@ -0,0 +1,372 @@
1
+ """
2
+ openframe/core/ports/outbound/repository.py
3
+ ==============================================
4
+ Generic persistence port for the OpenFrame ecosystem (ADR-006).
5
+
6
+ ``BaseRepository[T]`` extends
7
+ :class:`~openframe.core.ports.port.BasePort` directly — it is
8
+ ``Identity + Lifecycle`` plus its own domain methods. There is exactly one
9
+ lifecycle-aware definition of this port; the pre-v3 lifecycle-free version
10
+ no longer exists.
11
+
12
+ Adapters satisfy this protocol by structural subtyping — no inheritance
13
+ from ``BaseRepository`` is required or desired. Any class whose async
14
+ method signatures match, and that also satisfies ``BasePort``
15
+ (``name``/``version``/``capability``/``initialize``/``shutdown``/
16
+ ``health``), is accepted.
17
+
18
+ Runtime isinstance check::
19
+
20
+ isinstance(repo, BaseRepository) # ✓ works — checks method names
21
+ isinstance(repo, BaseRepository[str]) # ✗ raises TypeError — not supported
22
+
23
+ The parameterised form is a static-analysis-only annotation. At runtime,
24
+ always check against the unparameterised ``BaseRepository``.
25
+
26
+ This module also defines :class:`BaseVectorStore`, which extends
27
+ ``BaseRepository`` directly rather than sitting in its own file or as a
28
+ disjoint sibling port (the way ``BaseProducer``/``BaseConsumer`` are
29
+ siblings of ``BaseRepository``, not subtypes). Most vector databases ARE
30
+ genuinely id-addressable — insert/upsert-with-id, fetch-by-id,
31
+ delete-by-id, list/scroll — so the five CRUD methods below are real
32
+ operations for them too, not fakes forced to satisfy an interface. The
33
+ only operation with no equivalent here is similarity search, so
34
+ ``BaseVectorStore`` adds exactly one new method, :meth:`BaseVectorStore.search`,
35
+ on top of everything below. A vector store that satisfies
36
+ ``BaseVectorStore`` also satisfies ``BaseRepository`` for free — existing
37
+ code written against ``BaseRepository`` keeps working unmodified against
38
+ a vector-backed implementation.
39
+
40
+ ``BaseVectorStore`` is deliberately not a universal fit for every
41
+ vector backend. FAISS (a bare in-process index with no native
42
+ id→metadata store, and some index types that don't support deletion
43
+ without a full rebuild) was evaluated and explicitly excluded from this
44
+ port's intended adopters.
45
+
46
+ This module also defines :class:`BaseGraphStore`, which — like
47
+ ``BaseVectorStore`` — extends ``BaseRepository`` directly. A graph
48
+ database's nodes ARE genuinely id-addressable: create/get/update/delete
49
+ map onto real `CREATE`/`MATCH ... WHERE n.<key> = $id`/`SET`/`DETACH
50
+ DELETE` operations, provided the adapter addresses nodes by an
51
+ application-level unique property (with a backing index/constraint the
52
+ adapter is responsible for creating), not a backend-internal node
53
+ identity. FalkorDB's own ``id()`` function is explicitly documented as
54
+ unstable — deleted nodes' higher internal ids can be migrated down to
55
+ fill vacated lower ones — so it is unsuitable as this port's addressing
56
+ key; this is exactly why adapters must key off their own property, not
57
+ why graph databases don't fit this port at all. The one operation with
58
+ no CRUD equivalent is traversal/pattern-matching, so ``BaseGraphStore``
59
+ adds exactly one new method, :meth:`BaseGraphStore.traverse`, which also
60
+ serves as the escape hatch for relationship/edge creation and querying
61
+ (Cypher's ``CREATE``/``MATCH`` handle nodes and edges in the same
62
+ statement) — this port does not model edges as their own first-class
63
+ method, the same way it does not invent a bespoke query language.
64
+
65
+ Dependency order:
66
+ ports/port → ports/identity + ports/lifecycle
67
+ ports/outbound/repository → ports/port
68
+ """
69
+ from __future__ import annotations
70
+
71
+ from typing import Protocol, TypeVar, runtime_checkable
72
+
73
+ from openframe.core.ports.port import BasePort
74
+
75
+ __all__ = ["BaseRepository", "BaseVectorStore", "BaseGraphStore"]
76
+
77
+ T = TypeVar("T")
78
+
79
+
80
+ @runtime_checkable
81
+ class BaseRepository(BasePort, Protocol[T]):
82
+ """
83
+ Generic persistence port. ``BasePort`` (Identity + Lifecycle) plus
84
+ domain-specific CRUD methods.
85
+
86
+ Adapters implement this protocol structurally — no inheritance needed.
87
+ Any class with matching async method signatures, plus the
88
+ ``BasePort`` members, satisfies ``BaseRepository``.
89
+
90
+ Type parameter ``T`` is the domain entity the repository manages.
91
+
92
+ Runtime check::
93
+
94
+ isinstance(repo, BaseRepository) # ✓ works
95
+ isinstance(repo, BaseRepository[str]) # ✗ raises TypeError
96
+
97
+ Domain methods:
98
+ get: Retrieve a single entity by ID.
99
+ list: Return a paginated slice plus total count.
100
+ create: Persist a new entity and return it with any backend-assigned
101
+ fields populated (e.g. generated primary key, created_at).
102
+ update: Persist changes to an existing entity. Returns the updated
103
+ entity or None if the entity did not exist.
104
+ delete: Remove an entity by ID. Returns True if deleted, False if
105
+ the entity did not exist.
106
+
107
+ Plus, inherited from ``BasePort``: ``name``, ``version``, ``capability``,
108
+ ``initialize``, ``shutdown``, ``health``.
109
+ """
110
+
111
+ async def get(self, entity_id: str) -> T | None:
112
+ """
113
+ Retrieve a single entity by its identifier.
114
+
115
+ Args:
116
+ entity_id: The unique identifier of the entity.
117
+
118
+ Returns:
119
+ The entity if found, None if it does not exist.
120
+
121
+ Raises:
122
+ AdapterConnectionError: If the backend is unreachable.
123
+ AdapterQueryError: If the query fails for any other reason.
124
+ AdapterTimeoutError: If the operation exceeds ``operation_timeout``.
125
+ """
126
+ ...
127
+
128
+ async def list(self, limit: int, offset: int) -> tuple[list[T], int]:
129
+ """
130
+ Return a paginated slice of all entities plus the total count.
131
+
132
+ Args:
133
+ limit: Maximum number of entities to return.
134
+ offset: Number of entities to skip from the beginning.
135
+
136
+ Returns:
137
+ A 2-tuple of (entities, total_count). ``total_count`` is the
138
+ count of all matching entities, not just the returned slice.
139
+
140
+ Raises:
141
+ AdapterConnectionError: If the backend is unreachable.
142
+ AdapterQueryError: If the query fails.
143
+ AdapterTimeoutError: If the operation exceeds ``operation_timeout``.
144
+ """
145
+ ...
146
+
147
+ async def create(self, entity: T) -> T:
148
+ """
149
+ Persist a new entity and return it with backend-assigned fields.
150
+
151
+ Args:
152
+ entity: The entity to create. Backend may assign fields like
153
+ primary key or timestamps.
154
+
155
+ Returns:
156
+ The entity as stored, including any backend-assigned fields.
157
+
158
+ Raises:
159
+ AdapterConnectionError: If the backend is unreachable.
160
+ AdapterQueryError: If the insert fails (e.g. constraint violation).
161
+ AdapterTimeoutError: If the operation exceeds ``operation_timeout``.
162
+ """
163
+ ...
164
+
165
+ async def update(self, entity: T) -> T | None:
166
+ """
167
+ Persist changes to an existing entity.
168
+
169
+ Args:
170
+ entity: The entity with updated fields. Must have a valid identifier.
171
+
172
+ Returns:
173
+ The updated entity as stored, or None if the entity did not exist.
174
+
175
+ Raises:
176
+ AdapterConnectionError: If the backend is unreachable.
177
+ AdapterQueryError: If the update fails.
178
+ AdapterTimeoutError: If the operation exceeds ``operation_timeout``.
179
+ """
180
+ ...
181
+
182
+ async def delete(self, entity_id: str) -> bool:
183
+ """
184
+ Remove an entity by its identifier.
185
+
186
+ Args:
187
+ entity_id: The unique identifier of the entity to delete.
188
+
189
+ Returns:
190
+ True if the entity was deleted, False if it did not exist.
191
+
192
+ Raises:
193
+ AdapterConnectionError: If the backend is unreachable.
194
+ AdapterQueryError: If the deletion fails.
195
+ AdapterTimeoutError: If the operation exceeds ``operation_timeout``.
196
+ """
197
+ ...
198
+
199
+
200
+ @runtime_checkable
201
+ class BaseVectorStore(BaseRepository[T], Protocol[T]):
202
+ """
203
+ Generic vector store port. ``BaseRepository[T]`` (id-addressable CRUD)
204
+ plus one additional domain method, :meth:`search`.
205
+
206
+ See this module's own docstring for why this extends ``BaseRepository``
207
+ directly rather than being a disjoint sibling port, and why FAISS/
208
+ FalkorDB are explicitly excluded from this port's intended adopters.
209
+
210
+ Adapters implement this protocol structurally — no inheritance needed.
211
+ Any class with matching async method signatures, plus everything
212
+ ``BaseRepository``/``BasePort`` require, satisfies ``BaseVectorStore``.
213
+
214
+ Type parameter ``T`` is the domain entity the vector store manages —
215
+ typically a dataclass or Pydantic model carrying an id, an embedding
216
+ vector, and whatever metadata fields the caller's domain needs.
217
+
218
+ .. stability: experimental
219
+ New port, added alongside the first vector-store adapter packages.
220
+ API may change based on real usage. ``BaseRepository`` above is
221
+ unaffected by this — it remains stable; only ``BaseVectorStore``
222
+ itself is experimental.
223
+
224
+ Runtime check::
225
+
226
+ isinstance(store, BaseVectorStore) # ✓ works
227
+ isinstance(store, BaseRepository) # ✓ also true
228
+
229
+ Inherited from ``BaseRepository[T]`` — id-addressable CRUD:
230
+ get: Fetch a single entity by id.
231
+ list: Return a paginated slice plus total count.
232
+ create: Upsert a new entity (most vector DBs treat insert as
233
+ upsert-by-id natively; adapters document their own
234
+ create-vs-upsert semantics if they diverge).
235
+ update: Upsert an existing entity's vector/metadata.
236
+ delete: Remove an entity by id.
237
+
238
+ New in this port:
239
+ search: Similarity search — the operation ``BaseRepository`` has
240
+ no equivalent for.
241
+
242
+ Plus, inherited from ``BasePort`` (via ``BaseRepository``): ``name``,
243
+ ``version``, ``capability``, ``initialize``, ``shutdown``, ``health``.
244
+ """
245
+
246
+ async def search(self, query_vector: list[float], k: int) -> list[T]:
247
+ """
248
+ Return the ``k`` entities whose vectors are most similar to
249
+ ``query_vector``.
250
+
251
+ The distance/similarity metric (cosine, L2, dot product, ...) is
252
+ an adapter/backend-level configuration concern, not part of this
253
+ port's signature — different backends default to different
254
+ metrics, and some let it be configured per-collection at index
255
+ creation time. Document the active metric in the adapter's own
256
+ docstring.
257
+
258
+ Args:
259
+ query_vector: The embedding to search against. Must match the
260
+ dimensionality the backend collection/index was
261
+ created with — a mismatch is a configuration
262
+ error, not a query error, but adapters may
263
+ surface it as either depending on when the
264
+ backend itself detects it.
265
+ k: Maximum number of results to return, ordered from most to
266
+ least similar. Returning fewer than ``k`` results (if the
267
+ collection has fewer than ``k`` entities, or the backend
268
+ applies its own relevance cutoff) is not an error.
269
+
270
+ Returns:
271
+ Up to ``k`` entities, ordered from most to least similar.
272
+ Empty list if nothing matches (never ``None`` — unlike
273
+ ``get()``, "no results" is a normal outcome of a search, not
274
+ a not-found case).
275
+
276
+ Raises:
277
+ AdapterConnectionError: If the backend is unreachable.
278
+ AdapterQueryError: If the query fails (e.g.
279
+ ``query_vector``'s dimensionality
280
+ doesn't match the collection).
281
+ AdapterTimeoutError: If the operation exceeds
282
+ ``operation_timeout``.
283
+ """
284
+ ...
285
+
286
+
287
+ @runtime_checkable
288
+ class BaseGraphStore(BaseRepository[T], Protocol[T]):
289
+ """
290
+ Generic graph store port. ``BaseRepository[T]`` (id-addressable node
291
+ CRUD) plus one additional domain method, :meth:`traverse`.
292
+
293
+ See this module's own docstring for why this extends ``BaseRepository``
294
+ directly rather than being a disjoint sibling port, why FalkorDB's
295
+ internal ``id()`` is not a suitable addressing key, and why
296
+ relationships/edges are not given their own port method.
297
+
298
+ Adapters implement this protocol structurally — no inheritance needed.
299
+ Any class with matching async method signatures, plus everything
300
+ ``BaseRepository``/``BasePort`` require, satisfies ``BaseGraphStore``.
301
+
302
+ Type parameter ``T`` is the domain entity representing a **node** —
303
+ typically a dataclass or Pydantic model carrying an application-level
304
+ unique id, labels, and whatever properties the caller's domain needs.
305
+ Edges are not modeled by ``T`` or by any method on this port; they are
306
+ created and queried exclusively through :meth:`traverse`.
307
+
308
+ .. stability: experimental
309
+ New port, added alongside the first graph-store adapter packages.
310
+ API may change based on real usage. ``BaseRepository`` above is
311
+ unaffected by this — it remains stable; only ``BaseGraphStore``
312
+ itself is experimental.
313
+
314
+ Runtime check::
315
+
316
+ isinstance(store, BaseGraphStore) # ✓ works
317
+ isinstance(store, BaseRepository) # ✓ also true
318
+
319
+ Inherited from ``BaseRepository[T]`` — id-addressable node CRUD:
320
+ get: Fetch a single node by its application-level id.
321
+ list: Return a paginated slice of nodes plus total count.
322
+ create: Create a new node. Adapters are responsible for ensuring
323
+ a unique constraint/index exists on the id property they
324
+ address nodes by — without one, every get/update/delete
325
+ degrades to an unindexed label scan.
326
+ update: Update an existing node's properties.
327
+ delete: Remove a node by id. Adapters should detach (remove
328
+ incident edges) before deleting, since most graph backends
329
+ reject deleting a node that still has relationships.
330
+
331
+ New in this port:
332
+ traverse: Arbitrary pattern-matching/traversal query — the
333
+ operation ``BaseRepository`` has no equivalent for, and
334
+ also the only way to create, update, or query edges.
335
+
336
+ Plus, inherited from ``BasePort`` (via ``BaseRepository``): ``name``,
337
+ ``version``, ``capability``, ``initialize``, ``shutdown``, ``health``.
338
+ """
339
+
340
+ async def traverse(
341
+ self, query: str, params: dict[str, object] | None = None
342
+ ) -> list[T]:
343
+ """
344
+ Execute an arbitrary pattern-matching/traversal query against the
345
+ graph and return the matching entities.
346
+
347
+ This is the escape hatch for everything ``BaseRepository``'s CRUD
348
+ methods cannot express: multi-hop traversals, relationship/edge
349
+ creation and querying, variable-length paths, aggregations. The
350
+ query language itself (Cypher, Gremlin, ...) is an adapter/backend
351
+ concern, not part of this port's signature — adapters document
352
+ which language their ``query`` argument expects.
353
+
354
+ Args:
355
+ query: A query string in the backend's native query language.
356
+ params: Named parameters substituted into the query, keyed by
357
+ name as the backend's driver expects. None if the
358
+ query has no parameters.
359
+
360
+ Returns:
361
+ The entities the query's `RETURN`-equivalent clause produces.
362
+ Empty list if the query matches nothing (not an error).
363
+
364
+ Raises:
365
+ AdapterConnectionError: If the backend is unreachable.
366
+ AdapterQueryError: If the query is malformed or fails
367
+ (e.g. a syntax error, or a
368
+ constraint violation on a write).
369
+ AdapterTimeoutError: If the operation exceeds
370
+ ``operation_timeout``.
371
+ """
372
+ ...
@@ -33,6 +33,7 @@ Quick reference::
33
33
  from __future__ import annotations
34
34
 
35
35
  from openframe.core.testing.contracts.consumer import ConsumerContractTests
36
+ from openframe.core.testing.contracts.graph_store import GraphStoreContractTests
36
37
  from openframe.core.testing.contracts.inbound import (
37
38
  CommandHandlerContractTests,
38
39
  QueryHandlerContractTests,
@@ -42,7 +43,9 @@ from openframe.core.testing.contracts.lifecycle import LifecycleContractTests
42
43
  from openframe.core.testing.contracts.port import PortContractTests
43
44
  from openframe.core.testing.contracts.producer import ProducerContractTests
44
45
  from openframe.core.testing.contracts.repository import RepositoryContractTests
46
+ from openframe.core.testing.contracts.vector_store import VectorStoreContractTests
45
47
  from openframe.core.testing.fakes.consumer import FakeConsumer
48
+ from openframe.core.testing.fakes.graph_store import InMemoryGraphStore
46
49
  from openframe.core.testing.fakes.inbound import (
47
50
  EchoUseCase,
48
51
  SpyCommandHandler,
@@ -50,9 +53,12 @@ from openframe.core.testing.fakes.inbound import (
50
53
  )
51
54
  from openframe.core.testing.fakes.producer import FakeProducer
52
55
  from openframe.core.testing.fakes.repository import InMemoryRepository
56
+ from openframe.core.testing.fakes.vector_store import InMemoryVectorStore
53
57
 
54
58
  __all__ = [
55
59
  "InMemoryRepository",
60
+ "InMemoryVectorStore",
61
+ "InMemoryGraphStore",
56
62
  "FakeProducer",
57
63
  "FakeConsumer",
58
64
  "EchoUseCase",
@@ -61,6 +67,8 @@ __all__ = [
61
67
  "LifecycleContractTests",
62
68
  "PortContractTests",
63
69
  "RepositoryContractTests",
70
+ "VectorStoreContractTests",
71
+ "GraphStoreContractTests",
64
72
  "ProducerContractTests",
65
73
  "ConsumerContractTests",
66
74
  "UseCaseContractTests",
@@ -7,6 +7,12 @@ Outbound: ``PortContractTests``/``LifecycleContractTests`` cover the
7
7
  shared ``BasePort`` identity/lifecycle suite; ``RepositoryContractTests``,
8
8
  ``ProducerContractTests``, ``ConsumerContractTests`` build on
9
9
  ``PortContractTests`` with domain-specific assertions.
10
+ ``VectorStoreContractTests`` builds on ``RepositoryContractTests`` itself
11
+ (not directly on ``PortContractTests``), adding search-specific
12
+ assertions — mirroring how ``BaseVectorStore`` extends ``BaseRepository``.
13
+ ``GraphStoreContractTests`` likewise builds on ``RepositoryContractTests``,
14
+ adding traverse-specific assertions — mirroring how ``BaseGraphStore``
15
+ extends ``BaseRepository``.
10
16
 
11
17
  Inbound: ``UseCaseContractTests``, ``CommandHandlerContractTests``,
12
18
  ``QueryHandlerContractTests`` mirror the outbound contract classes for
@@ -22,6 +28,7 @@ pytest installed.
22
28
  from __future__ import annotations
23
29
 
24
30
  from openframe.core.testing.contracts.consumer import ConsumerContractTests
31
+ from openframe.core.testing.contracts.graph_store import GraphStoreContractTests
25
32
  from openframe.core.testing.contracts.inbound import (
26
33
  CommandHandlerContractTests,
27
34
  QueryHandlerContractTests,
@@ -31,11 +38,14 @@ from openframe.core.testing.contracts.lifecycle import LifecycleContractTests
31
38
  from openframe.core.testing.contracts.port import PortContractTests
32
39
  from openframe.core.testing.contracts.producer import ProducerContractTests
33
40
  from openframe.core.testing.contracts.repository import RepositoryContractTests
41
+ from openframe.core.testing.contracts.vector_store import VectorStoreContractTests
34
42
 
35
43
  __all__ = [
36
44
  "LifecycleContractTests",
37
45
  "PortContractTests",
38
46
  "RepositoryContractTests",
47
+ "VectorStoreContractTests",
48
+ "GraphStoreContractTests",
39
49
  "ProducerContractTests",
40
50
  "ConsumerContractTests",
41
51
  "UseCaseContractTests",
@@ -0,0 +1,143 @@
1
+ """
2
+ openframe/core/testing/contracts/graph_store.py
3
+ ==================================================
4
+ GraphStoreContractTests — reusable pytest base class.
5
+
6
+ Every ``openframe-adapters-db-{falkordb,...}`` package must inherit this
7
+ class and pass every test. Builds on
8
+ :class:`~openframe.core.testing.contracts.repository.RepositoryContractTests`
9
+ for the full id-addressable node CRUD contract (which itself builds on
10
+ :class:`~openframe.core.testing.contracts.port.PortContractTests` for
11
+ ``BasePort`` identity + lifecycle), adding
12
+ :meth:`~openframe.core.ports.BaseGraphStore.traverse`-specific assertions
13
+ on top.
14
+
15
+ The traverse assertions are deliberately query-language-agnostic — they
16
+ never hardcode Cypher (or any other graph query language) syntax, since
17
+ different backends use different languages. Instead, each assertion asks
18
+ a fixture, ``traverse_query_for``, to produce a ``(query, params)`` pair
19
+ in whatever language the adapter under test actually speaks, that is
20
+ guaranteed to return exactly the one entity passed to it. This mirrors
21
+ how :class:`~openframe.core.testing.contracts.vector_store.VectorStoreContractTests`
22
+ asks for an ``entity_vector`` extractor rather than assuming a specific
23
+ similarity metric.
24
+
25
+ No top-level pytest import — this module can be imported without pytest
26
+ installed, matching every other module in this package.
27
+
28
+ .. stability: experimental
29
+ New alongside :class:`~openframe.core.ports.BaseGraphStore` itself.
30
+
31
+ Dependency order:
32
+ testing/contracts/graph_store → testing/contracts/repository + ports
33
+
34
+ Subclass usage::
35
+
36
+ class TestFalkorDBGraphStore(GraphStoreContractTests):
37
+ @pytest.fixture
38
+ async def repository(self, falkordb_settings):
39
+ return FalkorDBNodeStore(falkordb_settings)
40
+
41
+ @pytest.fixture
42
+ def port(self, repository):
43
+ return repository
44
+
45
+ @pytest.fixture
46
+ def make_entity(self):
47
+ def _make(id: str, name: str = "test") -> Node:
48
+ return Node(id=id, name=name)
49
+ return _make
50
+
51
+ @pytest.fixture
52
+ def traverse_query_for(self):
53
+ # Given an entity, return a (query, params) pair that, run
54
+ # through traverse(), returns exactly that entity.
55
+ def _query_for(entity):
56
+ return "MATCH (n) WHERE n.id = $id RETURN n", {"id": entity.id}
57
+ return _query_for
58
+ """
59
+ from __future__ import annotations
60
+
61
+ from openframe.core.testing.contracts.repository import RepositoryContractTests
62
+
63
+ __all__ = ["GraphStoreContractTests"]
64
+
65
+ __stability__ = "experimental"
66
+
67
+
68
+ class GraphStoreContractTests(RepositoryContractTests):
69
+ """
70
+ Reusable pytest base class for graph store contract tests.
71
+
72
+ Subclasses must provide everything
73
+ :class:`~openframe.core.testing.contracts.repository.RepositoryContractTests`
74
+ requires (``repository``, ``port``, ``make_entity``), plus:
75
+
76
+ ``traverse_query_for``
77
+ A callable ``(entity: T) -> tuple[str, dict[str, object]]`` that,
78
+ given an entity, returns a ``(query, params)`` pair in the
79
+ adapter's own query language that — once the entity has been
80
+ created — returns exactly that one entity when passed to
81
+ :meth:`~openframe.core.ports.BaseGraphStore.traverse`. Lets these
82
+ contract tests exercise ``traverse()`` without hardcoding Cypher
83
+ (or any other) syntax.
84
+
85
+ .. stability: experimental
86
+ """
87
+
88
+ # ------------------------------------------------------------------
89
+ # traverse
90
+ # ------------------------------------------------------------------
91
+
92
+ async def test_traverse_returns_empty_list_when_nothing_matches(
93
+ self, repository, make_entity, traverse_query_for
94
+ ) -> None:
95
+ """traverse() returns an empty list, never None, when nothing matches."""
96
+ entity = make_entity(id="traverse-absent")
97
+ query, params = traverse_query_for(entity)
98
+
99
+ results = await repository.traverse(query, params)
100
+
101
+ assert results == []
102
+
103
+ async def test_traverse_returns_created_entity(
104
+ self, repository, make_entity, traverse_query_for
105
+ ) -> None:
106
+ """traverse() returns an entity once it has been created."""
107
+ entity = make_entity(id="traverse-target")
108
+ await repository.create(entity)
109
+ query, params = traverse_query_for(entity)
110
+
111
+ results = await repository.traverse(query, params)
112
+
113
+ assert entity in results
114
+
115
+ async def test_traverse_result_is_a_list(
116
+ self, repository, make_entity, traverse_query_for
117
+ ) -> None:
118
+ """traverse() always returns a list, regardless of match count."""
119
+ entity = make_entity(id="traverse-shape")
120
+ query, params = traverse_query_for(entity)
121
+
122
+ results = await repository.traverse(query, params)
123
+
124
+ assert isinstance(results, list)
125
+
126
+ async def test_traverse_accepts_no_params(self, repository) -> None:
127
+ """traverse() accepts params=None for queries with no parameters."""
128
+ results = await repository.traverse("RETURN 1", None)
129
+ assert isinstance(results, list)
130
+
131
+ # ------------------------------------------------------------------
132
+ # Protocol isinstance check
133
+ # ------------------------------------------------------------------
134
+
135
+ async def test_satisfies_base_graph_store_protocol(self, repository) -> None:
136
+ """Graph store satisfies BaseGraphStore via structural subtyping."""
137
+ from openframe.core.ports import BaseGraphStore
138
+
139
+ assert isinstance(repository, BaseGraphStore), (
140
+ f"{type(repository).__name__} does not satisfy BaseGraphStore. "
141
+ "Ensure it implements traverse, plus everything BaseRepository/"
142
+ "BasePort require."
143
+ )