tina4-python 3.13.43__tar.gz → 3.13.45__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 (158) hide show
  1. {tina4_python-3.13.43 → tina4_python-3.13.45}/PKG-INFO +1 -1
  2. {tina4_python-3.13.43 → tina4_python-3.13.45}/pyproject.toml +1 -1
  3. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/core/middleware.py +7 -4
  5. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/core/request.py +5 -1
  6. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/core/server.py +3 -0
  7. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/database/adapter.py +65 -5
  8. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/database/mssql.py +21 -4
  9. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/database/mysql.py +8 -1
  10. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/database/postgres.py +18 -2
  11. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/database/sqlite.py +21 -4
  12. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/dev_admin/__init__.py +11 -11
  13. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/migration/runner.py +18 -5
  14. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/queue_backends/kafka_backend.py +22 -3
  15. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/queue_backends/rabbitmq_backend.py +45 -7
  16. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/session/__init__.py +5 -1
  17. {tina4_python-3.13.43 → tina4_python-3.13.45}/.gitignore +0 -0
  18. {tina4_python-3.13.43 → tina4_python-3.13.45}/README.md +0 -0
  19. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/CLAUDE.md +0 -0
  20. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/HtmlElement.py +0 -0
  21. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/Testing.py +0 -0
  22. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/ai/__init__.py +0 -0
  23. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/api/__init__.py +0 -0
  24. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/auth/__init__.py +0 -0
  25. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/cache/__init__.py +0 -0
  26. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/cli/__init__.py +0 -0
  27. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/container/__init__.py +0 -0
  28. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/core/__init__.py +0 -0
  29. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/core/cache.py +0 -0
  30. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/core/constants.py +0 -0
  31. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/core/events.py +0 -0
  32. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/core/rate_limiter.py +0 -0
  33. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/core/response.py +0 -0
  34. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/core/router.py +0 -0
  35. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/crud/__init__.py +0 -0
  36. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/database/__init__.py +0 -0
  37. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/database/connection.py +0 -0
  38. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/database/firebird.py +0 -0
  39. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/database/mongodb.py +0 -0
  40. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/database/odbc.py +0 -0
  41. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/debug/__init__.py +0 -0
  42. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/debug/error_overlay.py +0 -0
  43. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/dev_admin/metrics.py +0 -0
  44. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/dev_admin/plan.py +0 -0
  45. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/dev_admin/project_index.py +0 -0
  46. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/docs.py +0 -0
  47. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/docstore/__init__.py +0 -0
  48. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/dotenv/__init__.py +0 -0
  49. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/env.py +0 -0
  50. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/frond/FROND.md +0 -0
  51. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/frond/__init__.py +0 -0
  52. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/frond/engine.py +0 -0
  53. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/auth/meta.json +0 -0
  54. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  55. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/database/meta.json +0 -0
  56. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  57. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/error-overlay/meta.json +0 -0
  58. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  59. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/orm/meta.json +0 -0
  60. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  61. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  62. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/queue/meta.json +0 -0
  63. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  64. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/rest-api/meta.json +0 -0
  65. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  66. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/templates/meta.json +0 -0
  67. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  68. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  69. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/graphql/__init__.py +0 -0
  70. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/i18n/__init__.py +0 -0
  71. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/mcp/__init__.py +0 -0
  72. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/mcp/protocol.py +0 -0
  73. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/mcp/tools.py +0 -0
  74. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/messenger/__init__.py +0 -0
  75. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/migration/__init__.py +0 -0
  76. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/orm/__init__.py +0 -0
  77. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/orm/fields.py +0 -0
  78. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/orm/model.py +0 -0
  79. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/public/__feedback/widget.js +0 -0
  80. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/public/css/tina4.css +0 -0
  81. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/public/css/tina4.min.css +0 -0
  82. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/public/favicon.ico +0 -0
  83. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/public/images/logo.svg +0 -0
  84. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  85. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/public/js/frond.js +0 -0
  86. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/public/js/frond.min.js +0 -0
  87. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  88. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  89. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/public/js/tina4.min.js +0 -0
  90. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/public/js/tina4js.min.js +0 -0
  91. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/public/swagger/index.html +0 -0
  92. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  93. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/query_builder/__init__.py +0 -0
  94. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/queue/__init__.py +0 -0
  95. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/queue/job.py +0 -0
  96. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/queue/kafka_backend.py +0 -0
  97. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/queue/lite_backend.py +0 -0
  98. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/queue/mongo_backend.py +0 -0
  99. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/queue/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/queue_backends/__init__.py +0 -0
  101. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/queue_backends/mongo_backend.py +0 -0
  102. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/__init__.py +0 -0
  103. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  104. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/_badges.scss +0 -0
  105. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  106. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/_cards.scss +0 -0
  107. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/_forms.scss +0 -0
  108. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/_grid.scss +0 -0
  109. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/_modals.scss +0 -0
  110. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/_nav.scss +0 -0
  111. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/_reset.scss +0 -0
  112. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/_tables.scss +0 -0
  113. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/_typography.scss +0 -0
  114. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  115. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/_variables.scss +0 -0
  116. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/base.scss +0 -0
  117. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/colors.scss +0 -0
  118. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/scss/tina4css/tina4.scss +0 -0
  119. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/seeder/__init__.py +0 -0
  120. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/service/__init__.py +0 -0
  121. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/session_handlers/__init__.py +0 -0
  122. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  123. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/session_handlers/redis_handler.py +0 -0
  124. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/session_handlers/valkey_handler.py +0 -0
  125. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/swagger/__init__.py +0 -0
  126. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/components/crud.twig +0 -0
  127. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  128. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  129. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/docker/python/Dockerfile +0 -0
  130. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  131. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/errors/302.twig +0 -0
  132. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/errors/401.twig +0 -0
  133. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/errors/403.twig +0 -0
  134. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/errors/404.twig +0 -0
  135. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/errors/500.twig +0 -0
  136. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/errors/502.twig +0 -0
  137. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/errors/503.twig +0 -0
  138. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/errors/base.twig +0 -0
  139. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/frontend/README.md +0 -0
  140. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/templates/readme.md +0 -0
  141. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/test/__init__.py +0 -0
  142. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/test_client/__init__.py +0 -0
  143. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  144. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  145. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  146. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  147. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  148. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  149. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  150. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  151. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  152. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  153. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  154. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  155. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/validator/__init__.py +0 -0
  156. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/websocket/__init__.py +0 -0
  157. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/websocket/backplane.py +0 -0
  158. {tina4_python-3.13.43 → tina4_python-3.13.45}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.13.43
3
+ Version: 3.13.45
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.43"
3
+ version = "3.13.45"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.13.43"
11
+ __version__ = "3.13.45"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -284,10 +284,13 @@ class CsrfMiddleware:
284
284
  @staticmethod
285
285
  def before_csrf(request, response):
286
286
  """Validate CSRF token before the route handler runs."""
287
- # Check if CSRF is enabled via env (middleware registration bypasses this)
288
- csrf_env = os.environ.get("TINA4_CSRF", "true").lower() not in ("false", "0", "no")
289
- # When registered via Router.use(), this method always runs.
290
- # The env check is only for auto-activation scenarios.
287
+ # TINA4_CSRF=false (or 0/no) disables all CSRF checks, even when the
288
+ # middleware is attached explicitly this is the documented kill
289
+ # switch ("TINA4_CSRF=false disables all checks"). When unset the
290
+ # default is enabled (true).
291
+ csrf_enabled = os.environ.get("TINA4_CSRF", "true").lower() not in ("false", "0", "no")
292
+ if not csrf_enabled:
293
+ return request, response
291
294
 
292
295
  # Skip safe HTTP methods
293
296
  method = getattr(request, "method", "GET").upper()
@@ -72,7 +72,7 @@ class Request:
72
72
  __slots__ = (
73
73
  "method", "path", "url", "query_string", "params", "query", "headers",
74
74
  "body", "raw_body", "cookies", "files", "ip", "remote_ip",
75
- "content_type", "session", "_route_params",
75
+ "content_type", "session", "_route_params", "_handler",
76
76
  )
77
77
 
78
78
  def __init__(self):
@@ -92,6 +92,10 @@ class Request:
92
92
  self.content_type: str = ""
93
93
  self.session = None # Set by session middleware
94
94
  self._route_params: dict = {} # Dynamic route params ({id}, etc.)
95
+ self._handler = None # Matched route handler — set by dispatch
96
+ # before middleware runs, so before_*
97
+ # middleware (e.g. CsrfMiddleware) can read
98
+ # handler metadata like _noauth.
95
99
 
96
100
  @classmethod
97
101
  def from_scope(cls, scope: dict, body: bytes = b"") -> "Request":
@@ -1753,6 +1753,9 @@ async def handle(request: Request) -> Response:
1753
1753
  if route:
1754
1754
  request._route_params = params
1755
1755
  request.merge_route_params()
1756
+ # Expose the matched handler on the request so before_* middleware
1757
+ # (e.g. CsrfMiddleware) can read handler metadata such as _noauth.
1758
+ request._handler = route.get("handler")
1756
1759
  try:
1757
1760
  skip = _check_auth(request, response, route)
1758
1761
  if not skip:
@@ -351,13 +351,32 @@ class DatabaseAdapter:
351
351
  ["Alice"], ["Bob"], ["Eve"]
352
352
  ])
353
353
  """
354
+ rows = params_list or []
355
+ if not rows:
356
+ return DatabaseResult(affected_rows=0)
357
+ # Run the whole batch in ONE transaction on ONE connection so it is
358
+ # atomic AND affected_rows/last_id are reliable. In autocommit mode each
359
+ # standalone execute() commits on its own (possibly different, pooled)
360
+ # connection, which scattered the per-row rowcount / last_insert_id and
361
+ # made the aggregate non-deterministic. When already inside an explicit
362
+ # transaction we just join it (never nest).
363
+ owns_txn = self._autocommit and not getattr(self, "_in_transaction", False)
364
+ if owns_txn:
365
+ self.start_transaction()
354
366
  total_affected = 0
355
367
  last_id = None
356
- for params in (params_list or []):
357
- result = self.execute(sql, params)
358
- total_affected += result.affected_rows
359
- if result.last_id is not None:
360
- last_id = result.last_id
368
+ try:
369
+ for params in rows:
370
+ result = self.execute(sql, params)
371
+ total_affected += result.affected_rows
372
+ if result.last_id is not None:
373
+ last_id = result.last_id
374
+ if owns_txn:
375
+ self.commit()
376
+ except Exception:
377
+ if owns_txn:
378
+ self.rollback()
379
+ raise
361
380
  return DatabaseResult(
362
381
  affected_rows=total_affected,
363
382
  last_id=last_id,
@@ -389,6 +408,47 @@ class DatabaseAdapter:
389
408
  stripped = stripped[:-1].rstrip()
390
409
  return stripped
391
410
 
411
+ @staticmethod
412
+ def _strip_trailing_order_by(sql: str) -> str:
413
+ """Strip a trailing top-level ``ORDER BY`` so the SQL can be safely
414
+ wrapped in ``SELECT COUNT(*) FROM (<sql>)`` for the row-count probe.
415
+
416
+ SQL Server rejects an ``ORDER BY`` inside a derived-table subquery
417
+ unless it carries ``TOP``/``OFFSET``/``FETCH`` (error 20018), which
418
+ silently zeroed the MSSQL count probe for any query ending in
419
+ ``ORDER BY`` (issue #262 -- the bug existed in this master adapter too,
420
+ not only the mirrors). ``ORDER BY`` does not affect ``COUNT(*)``, so
421
+ dropping it for the probe ONLY is safe; the paginated query keeps its
422
+ ``ORDER BY`` for ``OFFSET/FETCH``. An ``ORDER BY`` nested in a subquery,
423
+ or one already legalised by a following ``OFFSET``/``FETCH``/``FOR``, is
424
+ left intact. Parity with PHP ``SqlNormalizerTrait::stripTrailingOrderBy``.
425
+ """
426
+ if not sql or not re.search(r"\bORDER\s+BY\b", sql, re.IGNORECASE):
427
+ return sql
428
+ last_top_level = -1
429
+ for match in re.finditer(r"\bORDER\s+BY\b", sql, re.IGNORECASE):
430
+ pos = match.start()
431
+ before = sql[:pos]
432
+ balanced_before = before.count("(") == before.count(")")
433
+ depth = 0
434
+ balanced_after = True
435
+ for ch in sql[pos:]:
436
+ if ch == "(":
437
+ depth += 1
438
+ elif ch == ")":
439
+ depth -= 1
440
+ if depth < 0:
441
+ balanced_after = False
442
+ break
443
+ if balanced_before and balanced_after:
444
+ last_top_level = pos
445
+ if last_top_level == -1:
446
+ return sql
447
+ tail = sql[last_top_level:]
448
+ if re.search(r"\b(?:OFFSET|FETCH|FOR)\b", tail, re.IGNORECASE):
449
+ return sql
450
+ return sql[:last_top_level].rstrip()
451
+
392
452
  @staticmethod
393
453
  def _split_schema(name: str) -> tuple[str | None, str]:
394
454
  """Split a possibly-qualified table name into (schema, table).
@@ -64,6 +64,14 @@ class MSSQLAdapter(DatabaseAdapter):
64
64
  cursor = self._conn.cursor(as_dict=True)
65
65
  cursor.execute(sql, tuple(params) if params else ())
66
66
 
67
+ # Capture the affected-row count from the MAIN statement NOW, before any
68
+ # follow-up SELECT (SCOPE_IDENTITY / RETURNING fetch) runs on the SAME
69
+ # cursor and overwrites cursor.rowcount. Reading it at the end reflected
70
+ # the SCOPE_IDENTITY SELECT instead of the INSERT, so every INSERT
71
+ # reported affected_rows=0 (a batch insert summed to 0 even though the
72
+ # rows landed — surfaced by the live MySQL/MSSQL batch test).
73
+ affected = cursor.rowcount if cursor.rowcount is not None and cursor.rowcount >= 0 else 0
74
+
67
75
  records = []
68
76
  last_id = None
69
77
 
@@ -89,8 +97,6 @@ class MSSQLAdapter(DatabaseAdapter):
89
97
  if row:
90
98
  records = [dict(row)]
91
99
 
92
- affected = cursor.rowcount if cursor.rowcount >= 0 else 0
93
-
94
100
  if not self._in_transaction and self.autocommit:
95
101
  self._conn.commit()
96
102
 
@@ -116,7 +122,11 @@ class MSSQLAdapter(DatabaseAdapter):
116
122
  # the main cursor half-consumed, and the main query below is
117
123
  # deliberately NOT wrapped so its error FAILS LOUD (parity with
118
124
  # execute()) instead of looking like "no rows".
119
- count_sql = f"SELECT COUNT(*) AS cnt FROM ({sql}) AS _count_subquery"
125
+ # Strip a trailing top-level ORDER BY before wrapping: SQL Server rejects
126
+ # ORDER BY in a derived-table subquery without TOP/OFFSET/FETCH (#262),
127
+ # which otherwise zeroed the count for any query ending in ORDER BY. The
128
+ # paginated query below keeps its ORDER BY.
129
+ count_sql = f"SELECT COUNT(*) AS cnt FROM ({self._strip_trailing_order_by(sql)}) AS _count_subquery"
120
130
  probe = self._conn.cursor(as_dict=True)
121
131
  try:
122
132
  probe.execute(count_sql, tuple(params) if params else ())
@@ -155,7 +165,14 @@ class MSSQLAdapter(DatabaseAdapter):
155
165
  row = cursor.fetchone()
156
166
  return dict(row) if row else None
157
167
 
158
- def insert(self, table: str, data: dict) -> DatabaseResult:
168
+ def insert(self, table: str, data: dict | list) -> DatabaseResult:
169
+ # A list of dicts is a batch insert — delegate to the base class, which
170
+ # builds one parameterised INSERT and runs it per row via execute_many.
171
+ # (Database.insert / the docs advertise ``data: dict | list``; without
172
+ # this branch a list crashed with ``'list' object has no attribute
173
+ # 'keys'`` because this override only handled the single-dict case.)
174
+ if isinstance(data, list):
175
+ return super().insert(table, data)
159
176
  columns = ", ".join(data.keys())
160
177
  placeholders = ", ".join(["%s"] * len(data))
161
178
  sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})"
@@ -140,7 +140,14 @@ class MySQLAdapter(DatabaseAdapter):
140
140
  row = cursor.fetchone()
141
141
  return dict(row) if row else None
142
142
 
143
- def insert(self, table: str, data: dict) -> DatabaseResult:
143
+ def insert(self, table: str, data: dict | list) -> DatabaseResult:
144
+ # A list of dicts is a batch insert — delegate to the base class, which
145
+ # builds one parameterised INSERT and runs it per row via execute_many.
146
+ # (Database.insert / the docs advertise ``data: dict | list``; without
147
+ # this branch a list crashed with ``'list' object has no attribute
148
+ # 'keys'`` because this override only handled the single-dict case.)
149
+ if isinstance(data, list):
150
+ return super().insert(table, data)
144
151
  columns = ", ".join(data.keys())
145
152
  placeholders = ", ".join(["%s"] * len(data))
146
153
  sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})"
@@ -283,6 +283,12 @@ class PostgreSQLAdapter(DatabaseAdapter):
283
283
  cursor = self._conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
284
284
  self._exec_with_handling(cursor, sql, params)
285
285
 
286
+ # Capture rowcount NOW, before the lastval()/SAVEPOINT probes below run
287
+ # their own cursor.execute() calls and overwrite cursor.rowcount. A
288
+ # no-RETURNING INSERT was reporting affected_rows=0 because those probe
289
+ # statements clobbered the INSERT's rowcount before it was read at the end.
290
+ affected = cursor.rowcount if cursor.rowcount >= 0 else 0
291
+
286
292
  records = []
287
293
  last_id = None
288
294
 
@@ -317,7 +323,10 @@ class PostgreSQLAdapter(DatabaseAdapter):
317
323
  except Exception:
318
324
  cursor.execute("ROLLBACK TO SAVEPOINT _t4_lastval_probe")
319
325
 
320
- affected = cursor.rowcount if cursor.rowcount >= 0 else 0
326
+ # NOTE: affected_rows was captured right after the main statement above,
327
+ # before the lastval()/SAVEPOINT probes ran their own cursor.execute()
328
+ # calls (which reset cursor.rowcount). Do not recompute it from
329
+ # cursor.rowcount here or a no-RETURNING INSERT reports 0.
321
330
 
322
331
  if not self._in_transaction and self.autocommit:
323
332
  self._conn.commit()
@@ -417,7 +426,14 @@ class PostgreSQLAdapter(DatabaseAdapter):
417
426
  row[key] = bytes(value)
418
427
  return row
419
428
 
420
- def insert(self, table: str, data: dict) -> DatabaseResult:
429
+ def insert(self, table: str, data: dict | list) -> DatabaseResult:
430
+ # A list of dicts is a batch insert — delegate to the base class, which
431
+ # builds one parameterised INSERT and runs it per row via execute_many.
432
+ # (Database.insert / the docs advertise ``data: dict | list``; without
433
+ # this branch a list crashed with ``'list' object has no attribute
434
+ # 'keys'`` because this override only handled the single-dict case.)
435
+ if isinstance(data, list):
436
+ return super().insert(table, data)
421
437
  columns = ", ".join(data.keys())
422
438
  placeholders = ", ".join(["%s"] * len(data))
423
439
  sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders}) RETURNING *"
@@ -95,15 +95,25 @@ class SQLiteAdapter(DatabaseAdapter):
95
95
  def execute_many(self, sql: str, params_list: list[list] = None) -> DatabaseResult:
96
96
  """Optimized batch execute using SQLite's executemany."""
97
97
  sql = self._translate_sql(sql)
98
- cursor = self._conn.executemany(sql, params_list or [])
98
+ rows = params_list or []
99
+ self._conn.executemany(sql, rows)
100
+
101
+ # cursor.rowcount / cursor.lastrowid are unreliable after executemany()
102
+ # in sqlite3 (rowcount can come back 0 or -1, lastrowid is not set) and
103
+ # were non-deterministic across pooled connections. The batch is
104
+ # all-or-raise, so every supplied row was applied; the last inserted id
105
+ # is read deterministically from last_insert_rowid() on this connection.
106
+ affected = len(rows)
107
+ last_row = self._conn.execute("SELECT last_insert_rowid()").fetchone()
108
+ last_id = last_row[0] if last_row and last_row[0] else None
99
109
 
100
110
  if not self._in_transaction and self.autocommit:
101
111
  if self._conn.in_transaction:
102
112
  self._conn.execute("COMMIT")
103
113
 
104
114
  return DatabaseResult(
105
- affected_rows=cursor.rowcount,
106
- last_id=cursor.lastrowid,
115
+ affected_rows=affected,
116
+ last_id=last_id,
107
117
  )
108
118
 
109
119
  def fetch(self, sql: str, params: list = None,
@@ -144,7 +154,14 @@ class SQLiteAdapter(DatabaseAdapter):
144
154
  row = cursor.fetchone()
145
155
  return dict(row) if row else None
146
156
 
147
- def insert(self, table: str, data: dict) -> DatabaseResult:
157
+ def insert(self, table: str, data: dict | list) -> DatabaseResult:
158
+ # A list of dicts is a batch insert — delegate to the base class, which
159
+ # builds one parameterised INSERT and runs it per row via execute_many.
160
+ # (Database.insert / the docs advertise ``data: dict | list``; without
161
+ # this branch a list crashed with ``'list' object has no attribute
162
+ # 'keys'`` because this override only handled the single-dict case.)
163
+ if isinstance(data, list):
164
+ return super().insert(table, data)
148
165
  columns = ", ".join(data.keys())
149
166
  placeholders = ", ".join(["?"] * len(data))
150
167
  sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})"
@@ -159,10 +159,10 @@ class BrokenTracker:
159
159
 
160
160
  if filepath.exists():
161
161
  try:
162
- existing = json.loads(filepath.read_text())
162
+ existing = json.loads(filepath.read_text(encoding="utf-8"))
163
163
  existing["count"] = existing.get("count", 1) + 1
164
164
  existing["last_seen"] = datetime.now(timezone.utc).isoformat()
165
- filepath.write_text(json.dumps(existing, indent=2))
165
+ filepath.write_text(json.dumps(existing, indent=2), encoding="utf-8")
166
166
  return sig_hash
167
167
  except (json.JSONDecodeError, OSError):
168
168
  pass
@@ -178,7 +178,7 @@ class BrokenTracker:
178
178
  "last_seen": datetime.now(timezone.utc).isoformat(),
179
179
  "resolved": False,
180
180
  }
181
- filepath.write_text(json.dumps(entry, indent=2))
181
+ filepath.write_text(json.dumps(entry, indent=2), encoding="utf-8")
182
182
  return sig_hash
183
183
 
184
184
  @classmethod
@@ -190,7 +190,7 @@ class BrokenTracker:
190
190
  entries = []
191
191
  for f in sorted(broken_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
192
192
  try:
193
- entries.append(json.loads(f.read_text()))
193
+ entries.append(json.loads(f.read_text(encoding="utf-8")))
194
194
  except (json.JSONDecodeError, OSError):
195
195
  continue
196
196
  return entries
@@ -202,9 +202,9 @@ class BrokenTracker:
202
202
  if not filepath.exists():
203
203
  return False
204
204
  try:
205
- entry = json.loads(filepath.read_text())
205
+ entry = json.loads(filepath.read_text(encoding="utf-8"))
206
206
  entry["resolved"] = True
207
- filepath.write_text(json.dumps(entry, indent=2))
207
+ filepath.write_text(json.dumps(entry, indent=2), encoding="utf-8")
208
208
  return True
209
209
  except (json.JSONDecodeError, OSError):
210
210
  return False
@@ -217,7 +217,7 @@ class BrokenTracker:
217
217
  return
218
218
  for f in broken_dir.glob("*.json"):
219
219
  try:
220
- entry = json.loads(f.read_text())
220
+ entry = json.loads(f.read_text(encoding="utf-8"))
221
221
  if entry.get("resolved"):
222
222
  f.unlink()
223
223
  except (json.JSONDecodeError, OSError):
@@ -1791,7 +1791,7 @@ async def _api_connections(request, response):
1791
1791
  username = ""
1792
1792
  password = ""
1793
1793
  if env_path.exists():
1794
- for line in env_path.read_text().splitlines():
1794
+ for line in env_path.read_text(encoding="utf-8").splitlines():
1795
1795
  line = line.strip()
1796
1796
  if line.startswith("#") or "=" not in line:
1797
1797
  continue
@@ -1863,7 +1863,7 @@ async def _api_connections_save(request, response):
1863
1863
  env_path = Path(".env")
1864
1864
  lines = []
1865
1865
  if env_path.exists():
1866
- lines = env_path.read_text().splitlines()
1866
+ lines = env_path.read_text(encoding="utf-8").splitlines()
1867
1867
  keys_found = {"TINA4_DATABASE_URL": False, "TINA4_DATABASE_USERNAME": False, "TINA4_DATABASE_PASSWORD": False}
1868
1868
  new_lines = []
1869
1869
  for line in lines:
@@ -1891,7 +1891,7 @@ async def _api_connections_save(request, response):
1891
1891
  if not found:
1892
1892
  val = {"TINA4_DATABASE_URL": url, "TINA4_DATABASE_USERNAME": username, "TINA4_DATABASE_PASSWORD": password}[key]
1893
1893
  new_lines.append(f"{key}={val}")
1894
- env_path.write_text("\n".join(new_lines) + "\n")
1894
+ env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
1895
1895
  return response({"success": True})
1896
1896
  except Exception as e:
1897
1897
  return response({"success": False, "error": str(e)})
@@ -1906,7 +1906,7 @@ async def _api_gallery_list(request, response):
1906
1906
  for entry in sorted(gallery_dir.iterdir()):
1907
1907
  meta_file = entry / "meta.json"
1908
1908
  if entry.is_dir() and meta_file.exists():
1909
- meta = json.loads(meta_file.read_text())
1909
+ meta = json.loads(meta_file.read_text(encoding="utf-8"))
1910
1910
  meta["id"] = entry.name
1911
1911
  # List the files that would be deployed
1912
1912
  src_dir = entry / "src"
@@ -95,13 +95,26 @@ def _create_v3_table(db) -> None:
95
95
  )
96
96
  """)
97
97
  else:
98
- db.execute("""
98
+ # Engine-aware bookkeeping DDL. Each engine spells an auto-increment
99
+ # integer PK differently (SQLite AUTOINCREMENT, PostgreSQL SERIAL, MySQL
100
+ # AUTO_INCREMENT, MSSQL IDENTITY), and a TEXT column cannot carry a UNIQUE
101
+ # constraint on MySQL -- so migration_id is VARCHAR. (SQLite gives VARCHAR
102
+ # TEXT affinity, so this stays behaviour-identical there.) Mirrors the
103
+ # engine-aware DDL in ORM.create_table; without it `migrate()` died with
104
+ # "syntax error at AUTOINCREMENT" on PostgreSQL/MySQL/MSSQL.
105
+ engine = (db.get_database_type() or "sqlite").lower()
106
+ id_column = {
107
+ "postgresql": "id SERIAL PRIMARY KEY",
108
+ "mysql": "id INTEGER PRIMARY KEY AUTO_INCREMENT",
109
+ "mssql": "id INTEGER IDENTITY(1,1) PRIMARY KEY",
110
+ }.get(engine, "id INTEGER PRIMARY KEY AUTOINCREMENT")
111
+ db.execute(f"""
99
112
  CREATE TABLE tina4_migration (
100
- id INTEGER PRIMARY KEY AUTOINCREMENT,
101
- migration_id TEXT NOT NULL UNIQUE,
102
- description TEXT,
113
+ {id_column},
114
+ migration_id VARCHAR(500) NOT NULL UNIQUE,
115
+ description VARCHAR(500),
103
116
  batch INTEGER NOT NULL DEFAULT 1,
104
- executed_at TEXT NOT NULL,
117
+ executed_at VARCHAR(50) NOT NULL,
105
118
  passed INTEGER NOT NULL DEFAULT 1
106
119
  )
107
120
  """)
@@ -17,6 +17,7 @@ import os
17
17
  import secrets
18
18
  import socket
19
19
  import struct
20
+ import time
20
21
  import zlib
21
22
 
22
23
 
@@ -86,11 +87,29 @@ class KafkaConnector:
86
87
  self._ensure_connected()
87
88
 
88
89
  if self._use_confluent:
89
- if topic not in self._subscribed_topics:
90
+ first = topic not in self._subscribed_topics
91
+ if first:
90
92
  self._consumer.subscribe([topic])
91
93
  self._subscribed_topics.add(topic)
92
- msg = self._consumer.poll(timeout=1.0)
93
- if msg is None or msg.error():
94
+ # The FIRST poll after subscribing must drive the consumer-group
95
+ # join + partition assignment, which takes several seconds on a cold
96
+ # broker. Until partitions are assigned, poll() returns None even
97
+ # when the topic already has messages -- so a single short poll made
98
+ # dequeue() return None right after enqueue(). Poll in a bounded loop
99
+ # on first subscribe (deadline TINA4_KAFKA_ASSIGN_TIMEOUT, default
100
+ # 15s); steady state stays a single ~1s poll.
101
+ deadline = time.monotonic() + (
102
+ float(os.environ.get("TINA4_KAFKA_ASSIGN_TIMEOUT", "15")) if first else 1.0
103
+ )
104
+ msg = None
105
+ while True:
106
+ candidate = self._consumer.poll(timeout=0.5)
107
+ if candidate is not None and not candidate.error():
108
+ msg = candidate
109
+ break
110
+ if time.monotonic() >= deadline:
111
+ break
112
+ if msg is None:
94
113
  return None
95
114
  self._last_message = msg
96
115
  try:
@@ -135,7 +135,14 @@ class RabbitMQConnector:
135
135
  self._ensure_connected()
136
136
 
137
137
  if self._use_pika:
138
- result = self._channel.queue_declare(queue=topic, durable=True, passive=True)
138
+ # Declare durably (idempotent — creates the queue if missing) rather
139
+ # than passive=True. A passive declare raises ChannelClosedByBroker
140
+ # (404 NOT_FOUND) when the queue does not yet exist, so size() on a
141
+ # fresh topic crashed on the pika path. The raw-AMQP path already
142
+ # declares-then-counts and returns 0 for an unseen queue; this brings
143
+ # the pika path to the same behaviour.
144
+ self._ensure_queue_pika(topic)
145
+ result = self._channel.queue_declare(queue=topic, durable=True)
139
146
  return result.method.message_count
140
147
  else:
141
148
  return self._queue_size_raw(topic)
@@ -200,11 +207,13 @@ class RabbitMQConnector:
200
207
  # Connection.StartOk
201
208
  self._send_connection_start_ok()
202
209
 
203
- # Connection.Tune
204
- self._read_frame()
210
+ # Connection.Tune (the broker proposes channel-max, frame-max, heartbeat)
211
+ tune = self._read_frame()
205
212
 
206
- # Connection.TuneOk
207
- self._send_connection_tune_ok()
213
+ # Connection.TuneOk — negotiate from the broker's proposal (never exceed
214
+ # its channel-max; a hardcoded channel-max of 0 = "no limit" is treated as
215
+ # greater than the proposed 2047 and RabbitMQ aborts the handshake).
216
+ self._send_connection_tune_ok(tune)
208
217
 
209
218
  # Connection.Open
210
219
  self._send_connection_open()
@@ -299,8 +308,37 @@ class RabbitMQConnector:
299
308
  )
300
309
  self._write_method(0, 10, 11, args)
301
310
 
302
- def _send_connection_tune_ok(self):
303
- args = struct.pack("!HIH", 0, 131072, 60)
311
+ def _send_connection_tune_ok(self, tune: dict | None = None):
312
+ """Send Connection.TuneOk negotiated from the broker's Connection.Tune.
313
+
314
+ AMQP 0-9-1 requires the client's TuneOk values to not exceed the server's
315
+ proposal. The Tune payload (after the 4-byte class+method) is
316
+ channel-max:short, frame-max:long, heartbeat:short. We echo channel-max
317
+ (the server's cap; 0 = unlimited), clamp frame-max to min(desired, server)
318
+ treating 0 as unlimited, and choose a heartbeat. Hardcoding channel-max=0
319
+ means "no limit", which RabbitMQ treats as exceeding its proposed 2047 and
320
+ aborts the connection right after Open.
321
+ """
322
+ desired_frame_max = 131072
323
+ desired_heartbeat = 60
324
+
325
+ channel_max = 2047
326
+ frame_max = desired_frame_max
327
+ heartbeat = desired_heartbeat
328
+
329
+ if tune is not None and len(tune.get("payload", b"")) >= 12:
330
+ # payload: class_id(2) + method_id(2) + channel-max(2) + frame-max(4) + heartbeat(2)
331
+ _cls, _method, server_channel_max, server_frame_max, server_heartbeat = struct.unpack(
332
+ "!HHHIH", tune["payload"][:12]
333
+ )
334
+ # channel-max: never exceed the server's proposal (echo its value).
335
+ channel_max = server_channel_max
336
+ # frame-max: min(desired, server), treating 0 as unlimited on either side.
337
+ frame_max = desired_frame_max if server_frame_max == 0 else min(desired_frame_max, server_frame_max)
338
+ # heartbeat: our choice, clamped to the server's if it proposed a non-zero one.
339
+ heartbeat = desired_heartbeat if server_heartbeat == 0 else min(desired_heartbeat, server_heartbeat)
340
+
341
+ args = struct.pack("!HIH", channel_max, frame_max, heartbeat)
304
342
  self._write_method(0, 10, 31, args)
305
343
 
306
344
  def _send_connection_open(self):
@@ -195,7 +195,11 @@ class Session:
195
195
  from tina4_python.session_handlers import MongoDBSessionHandler
196
196
  return MongoDBSessionHandler()
197
197
  elif backend in ("database", "db"):
198
- return DatabaseSessionHandler()
198
+ # Resolve the same connection the ORM uses (global bound db, then
199
+ # TINA4_DATABASE_URL). DatabaseSessionHandler "uses whatever DB is
200
+ # connected" — so reuse the single ORM resolver rather than guess.
201
+ from tina4_python.orm.model import ORM
202
+ return DatabaseSessionHandler(ORM._get_db())
199
203
  else:
200
204
  return FileSessionHandler()
201
205
 
File without changes