domainkit 0.2.0__tar.gz → 0.3.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 (159) hide show
  1. {domainkit-0.2.0 → domainkit-0.3.0}/PKG-INFO +11 -4
  2. {domainkit-0.2.0 → domainkit-0.3.0}/README.md +8 -3
  3. {domainkit-0.2.0 → domainkit-0.3.0}/pyproject.toml +8 -1
  4. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/apps/config.py +10 -0
  5. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/apps/registry.py +7 -0
  6. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/conf/__init__.py +4 -0
  7. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/conf/global_settings.py +158 -3
  8. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/apps.py +12 -2
  9. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/management/commands/auditlog.py +14 -2
  10. domainkit-0.3.0/src/domainkit/contrib/evidence/__init__.py +60 -0
  11. domainkit-0.3.0/src/domainkit/contrib/evidence/apps.py +29 -0
  12. domainkit-0.3.0/src/domainkit/contrib/evidence/management/__init__.py +1 -0
  13. domainkit-0.3.0/src/domainkit/contrib/evidence/management/commands/__init__.py +1 -0
  14. domainkit-0.3.0/src/domainkit/contrib/evidence/management/commands/evidence.py +50 -0
  15. domainkit-0.3.0/src/domainkit/contrib/evidence/model.py +166 -0
  16. domainkit-0.3.0/src/domainkit/contrib/evidence/settings.py +16 -0
  17. domainkit-0.3.0/src/domainkit/contrib/evidence/store.py +131 -0
  18. domainkit-0.3.0/src/domainkit/contrib/evidence/tasks.py +32 -0
  19. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/idempotency/apps.py +8 -1
  20. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/admin.py +15 -7
  21. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/apps.py +8 -9
  22. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/management/commands/messages.py +16 -4
  23. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/management/commands/runworker.py +20 -4
  24. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/worker.py +20 -4
  25. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/connections.py +24 -1
  26. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/migrations.py +81 -38
  27. domainkit-0.3.0/src/domainkit/db/partitioning.py +344 -0
  28. domainkit-0.3.0/src/domainkit/db/routing.py +216 -0
  29. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/uow.py +35 -7
  30. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/application.py +3 -1
  31. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/health.py +2 -0
  32. domainkit-0.3.0/src/domainkit/ledger/__init__.py +37 -0
  33. domainkit-0.3.0/src/domainkit/ledger/base.py +75 -0
  34. domainkit-0.3.0/src/domainkit/ledger/handler.py +76 -0
  35. domainkit-0.3.0/src/domainkit/ledger/immudb.py +233 -0
  36. domainkit-0.3.0/src/domainkit/ledger/memory.py +42 -0
  37. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/commands/check.py +6 -5
  38. domainkit-0.3.0/src/domainkit/management/commands/makemigrations.py +89 -0
  39. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/commands/migrate.py +31 -10
  40. domainkit-0.3.0/src/domainkit/management/commands/partitions.py +55 -0
  41. domainkit-0.3.0/src/domainkit/management/commands/showmigrations.py +35 -0
  42. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/infrastructure/orm.py-tpl +2 -0
  43. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/testing.py +14 -10
  44. domainkit-0.2.0/src/domainkit/management/commands/makemigrations.py +0 -61
  45. domainkit-0.2.0/src/domainkit/management/commands/showmigrations.py +0 -26
  46. {domainkit-0.2.0 → domainkit-0.3.0}/LICENSE +0 -0
  47. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/__init__.py +0 -0
  48. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/apps/__init__.py +0 -0
  49. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/architecture.py +0 -0
  50. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/conf/env.py +0 -0
  51. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/conf/lazy.py +0 -0
  52. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/context.py +0 -0
  53. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/__init__.py +0 -0
  54. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/__init__.py +0 -0
  55. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/management/__init__.py +0 -0
  56. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/management/commands/__init__.py +0 -0
  57. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/protection.py +0 -0
  58. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/recorder.py +0 -0
  59. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/settings.py +0 -0
  60. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/tables.py +0 -0
  61. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/idempotency/__init__.py +0 -0
  62. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/idempotency/middleware.py +0 -0
  63. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/idempotency/settings.py +0 -0
  64. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/idempotency/tables.py +0 -0
  65. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/idempotency/tasks.py +0 -0
  66. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/__init__.py +0 -0
  67. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/management/__init__.py +0 -0
  68. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/management/commands/__init__.py +0 -0
  69. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/metrics.py +0 -0
  70. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/outbox.py +0 -0
  71. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/registry.py +0 -0
  72. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/settings.py +0 -0
  73. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/tables.py +0 -0
  74. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/__init__.py +0 -0
  75. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/drivers.py +0 -0
  76. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/migration_env/__init__.py +0 -0
  77. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/migration_env/env.py +0 -0
  78. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/migration_env/script.py.mako +0 -0
  79. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/orm.py +0 -0
  80. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/repository.py +0 -0
  81. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/types.py +0 -0
  82. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/__init__.py +0 -0
  83. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/_dataclass.py +0 -0
  84. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/events.py +0 -0
  85. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/exceptions.py +0 -0
  86. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/messages.py +0 -0
  87. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/model.py +0 -0
  88. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/pagination.py +0 -0
  89. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/repository.py +0 -0
  90. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/uow.py +0 -0
  91. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/deprecation.py +0 -0
  92. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/exceptions.py +0 -0
  93. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/__init__.py +0 -0
  94. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/errors.py +0 -0
  95. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/metrics.py +0 -0
  96. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/middleware.py +0 -0
  97. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/pagination.py +0 -0
  98. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/ratelimit.py +0 -0
  99. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/log.py +0 -0
  100. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/__init__.py +0 -0
  101. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/base.py +0 -0
  102. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/commands/__init__.py +0 -0
  103. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/commands/init.py +0 -0
  104. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/commands/runserver.py +0 -0
  105. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/commands/shell.py +0 -0
  106. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/commands/startdomain.py +0 -0
  107. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/__init__.py-tpl +0 -0
  108. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/api/__init__.py-tpl +0 -0
  109. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/api/dependencies.py-tpl +0 -0
  110. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/api/routes.py-tpl +0 -0
  111. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/api/schemas.py-tpl +0 -0
  112. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/application/__init__.py-tpl +0 -0
  113. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/application/commands.py-tpl +0 -0
  114. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/application/handlers.py-tpl +0 -0
  115. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/application/services.py-tpl +0 -0
  116. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/application/unit_of_work.py-tpl +0 -0
  117. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/apps.py-tpl +0 -0
  118. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/domain/__init__.py-tpl +0 -0
  119. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/domain/events.py-tpl +0 -0
  120. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/domain/exceptions.py-tpl +0 -0
  121. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/domain/model.py-tpl +0 -0
  122. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/domain/repositories.py-tpl +0 -0
  123. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/infrastructure/__init__.py-tpl +0 -0
  124. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/infrastructure/repositories.py-tpl +0 -0
  125. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/infrastructure/unit_of_work.py-tpl +0 -0
  126. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/management/__init__.py-tpl +0 -0
  127. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/management/commands/__init__.py-tpl +0 -0
  128. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/management/commands/{{ aggregates }}.py-tpl +0 -0
  129. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/settings.py-tpl +0 -0
  130. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/tests/__init__.py-tpl +0 -0
  131. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/tests/fakes.py-tpl +0 -0
  132. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/tests/test_api.py-tpl +0 -0
  133. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/tests/test_model.py-tpl +0 -0
  134. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/tests/test_services.py-tpl +0 -0
  135. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/tests/test_settings.py-tpl +0 -0
  136. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/dot-env-tpl +0 -0
  137. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/dot-gitignore-tpl +0 -0
  138. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/manage.py-tpl +0 -0
  139. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/pyproject.toml-tpl +0 -0
  140. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/__init__.py-tpl +0 -0
  141. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/asgi.py-tpl +0 -0
  142. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/settings/__init__.py-tpl +0 -0
  143. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/settings/base.py-tpl +0 -0
  144. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/settings/local.py-tpl +0 -0
  145. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/settings/production.py-tpl +0 -0
  146. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/settings/test.py-tpl +0 -0
  147. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/urls.py-tpl +0 -0
  148. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/domains/__init__.py-tpl +0 -0
  149. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/tests/__init__.py-tpl +0 -0
  150. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/tests/test_health.py-tpl +0 -0
  151. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/py.typed +0 -0
  152. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/reporting.py +0 -0
  153. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/tracing.py +0 -0
  154. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/utils/__init__.py +0 -0
  155. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/utils/formatting.py +0 -0
  156. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/utils/module_loading.py +0 -0
  157. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/utils/rates.py +0 -0
  158. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/utils/readiness.py +0 -0
  159. {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/utils/text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: domainkit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A Django-style framework for domain-driven FastAPI services.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
18
18
  Classifier: Typing :: Typed
19
+ Provides-Extra: immudb
19
20
  Provides-Extra: metrics
20
21
  Provides-Extra: postgres
21
22
  Provides-Extra: sqlite
@@ -26,6 +27,7 @@ Requires-Dist: alembic (>=1.20.0,<2.0.0)
26
27
  Requires-Dist: asyncpg (>=0.31.0,<0.32.0) ; extra == "postgres"
27
28
  Requires-Dist: fastapi (>=0.141.1,<0.142.0)
28
29
  Requires-Dist: httpx2 (>=2.13.0,<3.0.0) ; extra == "testing"
30
+ Requires-Dist: immudb-py (>=1.5.0,<1.6.0) ; extra == "immudb"
29
31
  Requires-Dist: opentelemetry-api (>=1.45.1,<2.0.0) ; extra == "tracing"
30
32
  Requires-Dist: prometheus-client (>=0.26.0,<0.27.0) ; extra == "metrics"
31
33
  Requires-Dist: pydantic (>=2.14.1,<3.0.0)
@@ -46,17 +48,22 @@ A Django-style framework for domain-driven FastAPI services. It provides:
46
48
  - settings modules and installed domains
47
49
  - `manage.py` commands and migrations
48
50
  - DDD building blocks, with the layer rules between them enforced
49
- - optional apps for reliable messaging, audit trails, and idempotent requests
51
+ - several databases: domains routed to their own, sharding by key,
52
+ PostgreSQL range partitioning, and immudb ledgers for evidence
53
+ - optional apps for reliable messaging, audit trails, idempotent requests,
54
+ and tamper-evident evidence
50
55
 
51
56
  ```bash
52
- pip install "domainkit[postgres]" # extras: postgres, sqlite, metrics, tracing, testing
57
+ pip install "domainkit[postgres]" # extras: postgres, sqlite, immudb, metrics, tracing, testing
53
58
  ```
54
59
 
55
60
  ## Documentation
56
61
 
57
62
  - [docs/reference.md](https://github.com/karimmattar/domainkit/blob/main/docs/reference.md): settings, middleware, errors,
58
63
  observability, testing, commands, and extension points
59
- - [docs/contrib.md](https://github.com/karimmattar/domainkit/blob/main/docs/contrib.md): messaging, audit, and idempotency
64
+ - [docs/databases.md](https://github.com/karimmattar/domainkit/blob/main/docs/databases.md): several databases, routing,
65
+ sharding, partitioning, and ledgers
66
+ - [docs/contrib.md](https://github.com/karimmattar/domainkit/blob/main/docs/contrib.md): messaging, audit, idempotency, and evidence
60
67
  - [docs/versioning.md](https://github.com/karimmattar/domainkit/blob/main/docs/versioning.md): compatibility, deprecations, and releases
61
68
  - [docs/adr/](https://github.com/karimmattar/domainkit/tree/main/docs/adr): the decisions behind the design
62
69
  - [CHANGELOG.md](https://github.com/karimmattar/domainkit/blob/main/CHANGELOG.md) and [SECURITY.md](https://github.com/karimmattar/domainkit/blob/main/SECURITY.md)
@@ -5,17 +5,22 @@ A Django-style framework for domain-driven FastAPI services. It provides:
5
5
  - settings modules and installed domains
6
6
  - `manage.py` commands and migrations
7
7
  - DDD building blocks, with the layer rules between them enforced
8
- - optional apps for reliable messaging, audit trails, and idempotent requests
8
+ - several databases: domains routed to their own, sharding by key,
9
+ PostgreSQL range partitioning, and immudb ledgers for evidence
10
+ - optional apps for reliable messaging, audit trails, idempotent requests,
11
+ and tamper-evident evidence
9
12
 
10
13
  ```bash
11
- pip install "domainkit[postgres]" # extras: postgres, sqlite, metrics, tracing, testing
14
+ pip install "domainkit[postgres]" # extras: postgres, sqlite, immudb, metrics, tracing, testing
12
15
  ```
13
16
 
14
17
  ## Documentation
15
18
 
16
19
  - [docs/reference.md](https://github.com/karimmattar/domainkit/blob/main/docs/reference.md): settings, middleware, errors,
17
20
  observability, testing, commands, and extension points
18
- - [docs/contrib.md](https://github.com/karimmattar/domainkit/blob/main/docs/contrib.md): messaging, audit, and idempotency
21
+ - [docs/databases.md](https://github.com/karimmattar/domainkit/blob/main/docs/databases.md): several databases, routing,
22
+ sharding, partitioning, and ledgers
23
+ - [docs/contrib.md](https://github.com/karimmattar/domainkit/blob/main/docs/contrib.md): messaging, audit, idempotency, and evidence
19
24
  - [docs/versioning.md](https://github.com/karimmattar/domainkit/blob/main/docs/versioning.md): compatibility, deprecations, and releases
20
25
  - [docs/adr/](https://github.com/karimmattar/domainkit/tree/main/docs/adr): the decisions behind the design
21
26
  - [CHANGELOG.md](https://github.com/karimmattar/domainkit/blob/main/CHANGELOG.md) and [SECURITY.md](https://github.com/karimmattar/domainkit/blob/main/SECURITY.md)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "domainkit"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "A Django-style framework for domain-driven FastAPI services."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -35,6 +35,8 @@ postgres = ["asyncpg (>=0.31.0,<0.32.0)"]
35
35
  sqlite = ["aiosqlite (>=0.22.1,<0.23.0)"]
36
36
  metrics = ["prometheus-client (>=0.26.0,<0.27.0)"]
37
37
  tracing = ["opentelemetry-api (>=1.45.1,<2.0.0)"]
38
+ # Ledgers for evidence (WORM): immudb's SDK, with its verified operations.
39
+ immudb = ["immudb-py (>=1.5.0,<1.6.0)"]
38
40
  # The domainkit.testing pytest plugin; Starlette's TestClient needs httpx2.
39
41
  testing = ["pytest (>=9.1.1,<10.0.0)", "httpx2 (>=2.13.0,<3.0.0)"]
40
42
 
@@ -104,6 +106,11 @@ mypy_path = "src:."
104
106
  files = ["src", "domainkit_tests"]
105
107
  exclude = ["/management/templates/", "/mutants/"]
106
108
 
109
+ # immudb-py and grpcio ship no type information.
110
+ [[tool.mypy.overrides]]
111
+ module = ["immudb", "immudb.*", "grpc", "grpc.*", "ecdsa", "ecdsa.*"]
112
+ ignore_missing_imports = true
113
+
107
114
  [tool.pytest.ini_options]
108
115
  testpaths = ["domainkit_tests"]
109
116
  pythonpath = ["."]
@@ -11,6 +11,7 @@ from pydantic import BaseModel
11
11
  from domainkit.exceptions import ImproperlyConfigured
12
12
 
13
13
  if TYPE_CHECKING:
14
+ from domainkit.db.routing import DatabaseRouter
14
15
  from domainkit.ddd import DeferredTask, DomainEvent, UnitOfWork
15
16
 
16
17
  CONFIG_MODULE = "apps"
@@ -100,6 +101,15 @@ class DomainConfig:
100
101
  ) -> None:
101
102
  """Add rows to a committing SQL unit of work (outbox, audit trail)."""
102
103
 
104
+ def databases(self, router: "DatabaseRouter") -> frozenset[str]:
105
+ """The targets that hold this domain's tables (see ``DATABASE_ROUTING``).
106
+
107
+ By default, the domain's routing target. Apps whose rows must commit
108
+ with every domain's data (the outbox, the audit trail) return every
109
+ hosting target; apps without tables return none.
110
+ """
111
+ return frozenset({router.target_for(self.label)})
112
+
103
113
 
104
114
  def _import_domain(name: str) -> ModuleType:
105
115
  """Import a domain package, explaining a missing one."""
@@ -123,6 +123,13 @@ class DomainRegistry:
123
123
  + ", ".join(_describe_unknown(name, known) for name in unknown)
124
124
  + ". Declare them in a domain's settings_class or SETTINGS_CLASS."
125
125
  )
126
+ labels = list(self._configs)
127
+ if unrouted := sorted(set(current.DATABASE_ROUTING) - set(labels)):
128
+ raise ImproperlyConfigured(
129
+ "DATABASE_ROUTING names domains that are not installed: "
130
+ + ", ".join(_describe_unknown(label, labels) for label in unrouted)
131
+ + "."
132
+ )
126
133
 
127
134
 
128
135
  def _describe_unknown(name: str, known: list[str]) -> str:
@@ -20,7 +20,9 @@ from domainkit.conf.env import Env
20
20
  from domainkit.conf.global_settings import (
21
21
  DEFAULT_DB_ALIAS,
22
22
  DatabaseConfig,
23
+ LedgerConfig,
23
24
  Settings,
25
+ ShardConfig,
24
26
  )
25
27
  from domainkit.conf.lazy import (
26
28
  SETTINGS_MODULE_VARIABLE,
@@ -46,7 +48,9 @@ __all__ = [
46
48
  "SETTINGS_MODULE_VARIABLE",
47
49
  "UNSET",
48
50
  "DatabaseConfig",
51
+ "LedgerConfig",
49
52
  "Settings",
53
+ "ShardConfig",
50
54
  "configure",
51
55
  "describe_errors",
52
56
  "load_settings",
@@ -9,6 +9,7 @@ so a misspelled setting fails at startup instead of being silently ignored.
9
9
  from collections.abc import Callable
10
10
  from pathlib import Path
11
11
  from typing import Any, Final, Literal, Self
12
+ from urllib.parse import urlsplit
12
13
 
13
14
  from pydantic import (
14
15
  BaseModel,
@@ -51,6 +52,11 @@ def is_network_database(url: str) -> bool:
51
52
  return not url.split(":", 1)[0].split("+", 1)[0].startswith("sqlite")
52
53
 
53
54
 
55
+ LEDGER_SCHEMES: Final = frozenset({"immudb", "memory"})
56
+ """URL schemes of ledgers (``LEDGERS``); ``DATABASES`` refuses them."""
57
+ IMMUDB_PORT: Final = 3322
58
+
59
+
54
60
  class DatabaseConfig(BaseModel):
55
61
  """One ``DATABASES`` entry: an SQLAlchemy async URL plus connection options."""
56
62
 
@@ -95,6 +101,108 @@ class DatabaseConfig(BaseModel):
95
101
  raise ValueError("SSLCERT and SSLKEY must be set together")
96
102
  return self
97
103
 
104
+ @model_validator(mode="after")
105
+ def check_not_a_ledger(self) -> Self:
106
+ """Point ledger URLs to ``LEDGERS``, where they belong."""
107
+ scheme = urlsplit(self.URL.get_secret_value()).scheme.split("+", 1)[0]
108
+ if scheme in LEDGER_SCHEMES:
109
+ raise ValueError(f"{scheme}:// is a ledger; configure it in LEDGERS")
110
+ return self
111
+
112
+
113
+ class LedgerConfig(BaseModel):
114
+ """One ``LEDGERS`` entry: write once, read many, verified on the client.
115
+
116
+ Ledgers hold evidence (see ``domainkit.contrib.evidence``). They are never
117
+ migrated and never take part in a unit of work.
118
+ """
119
+
120
+ model_config = ConfigDict(frozen=True, extra="forbid")
121
+
122
+ URL: SecretStr
123
+ """``immudb://user:password@host:3322/database``, or ``memory://`` for tests."""
124
+ TLS_CA: Path | None = None
125
+ """CA certificate that signed the server's; setting it enables TLS."""
126
+ SERVER_SIGNING_KEY: Path | None = None
127
+ """Public key the server signs its states with (immudb ``--signingKey``)."""
128
+ TIMEOUT: float = Field(default=10.0, gt=0)
129
+ """Seconds each ledger call may take."""
130
+
131
+ @model_validator(mode="after")
132
+ def check_url(self) -> Self:
133
+ """Accept immudb URLs with a host, or the in-memory ledger."""
134
+ parts = urlsplit(self.URL.get_secret_value())
135
+ if parts.scheme not in LEDGER_SCHEMES:
136
+ raise ValueError("a ledger URL starts with immudb:// or memory://")
137
+ if parts.scheme == "immudb" and not parts.hostname:
138
+ raise ValueError("an immudb URL needs a host: immudb://user:pw@host/db")
139
+ return self
140
+
141
+ @property
142
+ def scheme(self) -> str:
143
+ """``immudb`` or ``memory``."""
144
+ return urlsplit(self.URL.get_secret_value()).scheme
145
+
146
+
147
+ class ShardConfig(BaseModel):
148
+ """One ``SHARDS`` group: SQL databases that share a schema, split by key.
149
+
150
+ A shard key hashes to one of ``BUCKETS`` buckets, and each bucket belongs
151
+ to one database. Moving buckets between databases is how a group grows.
152
+ """
153
+
154
+ model_config = ConfigDict(frozen=True, extra="forbid")
155
+
156
+ DATABASES: tuple[str, ...] = Field(min_length=1)
157
+ """The group's SQL aliases; ``makemigrations`` compares against the first."""
158
+ BUCKETS: int = Field(default=4096, ge=1, le=1_048_576)
159
+ """Number of hash buckets. Never change it once the group holds data."""
160
+ RANGES: dict[str, tuple[tuple[int, int], ...]] = Field(default_factory=dict)
161
+ """Inclusive bucket ranges per alias; default: equal ranges in list order."""
162
+ PINNED: dict[str, str] = Field(default_factory=dict)
163
+ """Shard keys (as text) always routed to an alias, such as a large tenant."""
164
+
165
+ @model_validator(mode="after")
166
+ def check_layout(self) -> Self:
167
+ """Every bucket has exactly one owner, and pins stay inside the group."""
168
+ if len(set(self.DATABASES)) != len(self.DATABASES):
169
+ raise ValueError("a shard group lists an alias twice")
170
+ if len(self.DATABASES) > self.BUCKETS:
171
+ raise ValueError("BUCKETS must be at least the number of DATABASES")
172
+ if outside := sorted(set(self.PINNED.values()) - set(self.DATABASES)):
173
+ raise ValueError(f"PINNED names aliases outside the group: {outside}")
174
+ if not self.RANGES:
175
+ return self
176
+ if set(self.RANGES) != set(self.DATABASES):
177
+ raise ValueError("RANGES must give ranges for exactly the group's aliases")
178
+ owned = sorted(
179
+ (low, high) for ranges in self.RANGES.values() for low, high in ranges
180
+ )
181
+ expected = 0
182
+ for low, high in owned:
183
+ if low != expected or high < low:
184
+ raise ValueError(
185
+ f"RANGES must cover buckets 0-{self.BUCKETS - 1} once each; "
186
+ f"bucket {expected} is missing or overlapped"
187
+ )
188
+ expected = high + 1
189
+ if expected != self.BUCKETS:
190
+ raise ValueError(
191
+ f"RANGES must cover buckets 0-{self.BUCKETS - 1} once each; "
192
+ f"they end at {expected - 1}"
193
+ )
194
+ return self
195
+
196
+ def ranges(self) -> dict[str, tuple[tuple[int, int], ...]]:
197
+ """Bucket ranges per alias: ``RANGES``, or equal contiguous ranges."""
198
+ if self.RANGES:
199
+ return dict(self.RANGES)
200
+ count, buckets = len(self.DATABASES), self.BUCKETS
201
+ return {
202
+ alias: ((index * buckets // count, (index + 1) * buckets // count - 1),)
203
+ for index, alias in enumerate(self.DATABASES)
204
+ }
205
+
98
206
 
99
207
  class Settings(BaseModel):
100
208
  """Validated, read-only settings; extra names are checked at ``setup()``."""
@@ -120,8 +228,15 @@ class Settings(BaseModel):
120
228
  """``module:attribute`` that ``runserver`` serves."""
121
229
 
122
230
  DATABASES: dict[str, DatabaseConfig] = Field(default_factory=dict)
231
+ """SQL databases by alias; if any are set, ``default`` is required."""
232
+ LEDGERS: dict[str, LedgerConfig] = Field(default_factory=dict)
233
+ """Ledgers by alias: write once, read many (``immudb://``, ``memory://``)."""
234
+ SHARDS: dict[str, ShardConfig] = Field(default_factory=dict)
235
+ """Shard groups by name; route a domain to one in ``DATABASE_ROUTING``."""
236
+ DATABASE_ROUTING: dict[str, str] = Field(default_factory=dict)
237
+ """Domain label to target: a SQL alias or a shard group; others use default."""
123
238
  MIGRATIONS_DIR: Path = Field(default_factory=lambda: project_root() / "migrations")
124
- """Where ``makemigrations`` writes revision files."""
239
+ """Revisions of ``default``; any other target uses ``MIGRATIONS_DIR/<target>``."""
125
240
  LOGGING: dict[str, Any] = Field(default_factory=_minimal_logging)
126
241
  """``logging.config.dictConfig`` mapping applied by ``setup()``."""
127
242
 
@@ -171,8 +286,7 @@ class Settings(BaseModel):
171
286
  @model_validator(mode="after")
172
287
  def check_consistency(self) -> Self:
173
288
  """Reject combinations that are unsafe or would fail later at runtime."""
174
- if self.DATABASES and DEFAULT_DB_ALIAS not in self.DATABASES:
175
- raise ValueError(f"DATABASES must define the {DEFAULT_DB_ALIAS!r} alias")
289
+ self._check_databases()
176
290
  if len(set(self.INSTALLED_DOMAINS)) != len(self.INSTALLED_DOMAINS):
177
291
  raise ValueError("INSTALLED_DOMAINS contains duplicates")
178
292
  if self.CORS_ALLOW_CREDENTIALS and "*" in self.CORS_ALLOWED_ORIGINS:
@@ -183,6 +297,34 @@ class Settings(BaseModel):
183
297
  self._check_deployment()
184
298
  return self
185
299
 
300
+ def _check_databases(self) -> None:
301
+ """Validate aliases, shard groups, and routing targets together."""
302
+ sql = self.DATABASES
303
+ if sql and DEFAULT_DB_ALIAS not in sql:
304
+ raise ValueError(f"DATABASES must define the {DEFAULT_DB_ALIAS!r} alias")
305
+ sharded: dict[str, str] = {}
306
+ for group, shards in self.SHARDS.items():
307
+ if group in self.DATABASES:
308
+ raise ValueError(f"shard group {group!r} has the name of an alias")
309
+ for alias in shards.DATABASES:
310
+ if alias not in sql or alias == DEFAULT_DB_ALIAS:
311
+ raise ValueError(
312
+ f"shard group {group!r}: {alias!r} must be a SQL alias "
313
+ f"other than {DEFAULT_DB_ALIAS!r}"
314
+ )
315
+ if alias in sharded:
316
+ raise ValueError(
317
+ f"{alias!r} is in shard groups {sharded[alias]!r} and {group!r}"
318
+ )
319
+ sharded[alias] = group
320
+ for label, target in self.DATABASE_ROUTING.items():
321
+ if target in self.SHARDS or (target in sql and target not in sharded):
322
+ continue
323
+ raise ValueError(
324
+ f"DATABASE_ROUTING[{label!r}] = {target!r} must name a shard group "
325
+ "or a SQL alias outside the shard groups"
326
+ )
327
+
186
328
  def _check_deployment(self) -> None:
187
329
  """Enforce the security baseline for staging and production."""
188
330
  where = f"in {self.ENVIRONMENT}"
@@ -203,3 +345,16 @@ class Settings(BaseModel):
203
345
  for message, violates in rules.items():
204
346
  if aliases := sorted(a for a, db in self.DATABASES.items() if violates(db)):
205
347
  raise ValueError(f"{message} {where} for {aliases}")
348
+ ledger_rules: dict[str, Callable[[LedgerConfig], bool]] = {
349
+ "memory:// ledgers are for tests; use immudb": lambda ledger: (
350
+ ledger.scheme == "memory"
351
+ ),
352
+ "ledgers need TLS_CA (TLS)": lambda ledger: ledger.TLS_CA is None,
353
+ "ledgers need SERVER_SIGNING_KEY (signed states)": lambda ledger: (
354
+ ledger.SERVER_SIGNING_KEY is None
355
+ ),
356
+ }
357
+ for message, violates_ledger in ledger_rules.items():
358
+ ledgers = self.LEDGERS.items()
359
+ if aliases := sorted(a for a, db in ledgers if violates_ledger(db)):
360
+ raise ValueError(f"{message} {where} for LEDGERS {aliases}")
@@ -5,6 +5,7 @@ from typing import cast
5
5
 
6
6
  from domainkit.apps import DomainConfig, registry
7
7
  from domainkit.db import SqlAlchemyUnitOfWork
8
+ from domainkit.db.routing import DatabaseRouter, router
8
9
  from domainkit.ddd import DeferredTask, DomainEvent, UnitOfWork
9
10
 
10
11
  from .recorder import event_rows, write
@@ -18,6 +19,11 @@ class AuditConfig(DomainConfig):
18
19
  verbose_name = "Audit trail"
19
20
  settings_class = AuditSettings
20
21
 
22
+ def ready(self) -> None:
23
+ """Check where explicit records go."""
24
+ config = registry.settings(AuditSettings)
25
+ router.require_standalone(config.DATABASE, "AUDIT.DATABASE")
26
+
21
27
  async def stage(
22
28
  self,
23
29
  uow: UnitOfWork,
@@ -26,7 +32,11 @@ class AuditConfig(DomainConfig):
26
32
  ) -> None:
27
33
  """Add one audit row per committed event (unless excluded)."""
28
34
  config = registry.settings(AuditSettings)
29
- sql_uow = cast(SqlAlchemyUnitOfWork, uow)
30
- if not config.EVENTS or sql_uow.database != config.DATABASE:
35
+ if not config.EVENTS:
31
36
  return
37
+ sql_uow = cast(SqlAlchemyUnitOfWork, uow)
32
38
  await write(sql_uow.session, event_rows(events, config))
39
+
40
+ def databases(self, router: DatabaseRouter) -> frozenset[str]:
41
+ """Every hosting target: events are audited where they commit."""
42
+ return frozenset(router.hosting_targets())
@@ -21,9 +21,20 @@ class Command(BaseCommand):
21
21
  parser.add_argument("--actor")
22
22
  parser.add_argument("--action")
23
23
  parser.add_argument("--limit", type=int, default=50)
24
+ parser.add_argument(
25
+ "--database",
26
+ help="the alias to read (default: AUDIT.DATABASE); events are audited "
27
+ "in the database where they commit",
28
+ )
24
29
 
25
30
  async def handle(
26
- self, *, actor: str | None, action: str | None, limit: int, **options: object
31
+ self,
32
+ *,
33
+ actor: str | None,
34
+ action: str | None,
35
+ limit: int,
36
+ database: str | None,
37
+ **options: object,
27
38
  ) -> None:
28
39
  """Print one line per entry."""
29
40
  config = registry.settings(AuditSettings)
@@ -32,7 +43,8 @@ class Command(BaseCommand):
32
43
  statement = statement.where(audit_log.c.actor == actor)
33
44
  if action:
34
45
  statement = statement.where(audit_log.c.action == action)
35
- async with connections.engine(config.DATABASE).connect() as connection:
46
+ alias = database or config.DATABASE
47
+ async with connections.engine(alias).connect() as connection:
36
48
  rows = (await connection.execute(statement.limit(limit))).all()
37
49
  for row in rows:
38
50
  self.write(
@@ -0,0 +1,60 @@
1
+ """Evidence: facts kept on a ledger, provably unchanged (see ADR 0011).
2
+
3
+ Install ``domainkit.contrib.evidence``, configure a ledger, and record::
4
+
5
+ LEDGERS = {"evidence": {"URL": "immudb://user:pw@immudb:3322/evidence"}}
6
+
7
+ store = evidence_store()
8
+ receipt = await store.record(
9
+ Evidence(kind="kyc.document_verified", subject="customer:42",
10
+ facts={"result": "match"}, artifacts={"passport": sha256(data)})
11
+ )
12
+ await store.verify(receipt)
13
+
14
+ Inside a use case, ``defer_evidence(uow, evidence)`` records it through the
15
+ outbox once the change commits.
16
+ """
17
+
18
+ from domainkit.apps import registry
19
+ from domainkit.contrib.evidence.model import (
20
+ Evidence,
21
+ EvidenceConflict,
22
+ EvidenceNotFound,
23
+ EvidenceReceipt,
24
+ EvidenceTampered,
25
+ RecordedEvidence,
26
+ sha256,
27
+ )
28
+ from domainkit.contrib.evidence.settings import EvidenceSettings
29
+ from domainkit.contrib.evidence.store import (
30
+ EvidenceStore,
31
+ LedgerEvidenceStore,
32
+ evidence_key,
33
+ subject_digest,
34
+ )
35
+ from domainkit.ledger import ledgers
36
+
37
+
38
+ def evidence_store() -> EvidenceStore:
39
+ """The configured store, on the ledger ``EVIDENCE.LEDGER`` names."""
40
+ config = registry.settings(EvidenceSettings)
41
+ return LedgerEvidenceStore(
42
+ ledgers[config.LEDGER], namespace=config.NAMESPACE, max_bytes=config.MAX_BYTES
43
+ )
44
+
45
+
46
+ __all__ = [
47
+ "Evidence",
48
+ "EvidenceConflict",
49
+ "EvidenceNotFound",
50
+ "EvidenceReceipt",
51
+ "EvidenceSettings",
52
+ "EvidenceStore",
53
+ "EvidenceTampered",
54
+ "LedgerEvidenceStore",
55
+ "RecordedEvidence",
56
+ "evidence_key",
57
+ "evidence_store",
58
+ "sha256",
59
+ "subject_digest",
60
+ ]
@@ -0,0 +1,29 @@
1
+ """Install ``domainkit.contrib.evidence`` to record evidence on a ledger."""
2
+
3
+ from importlib import import_module
4
+
5
+ from domainkit.apps import DomainConfig, registry
6
+ from domainkit.db.routing import DatabaseRouter
7
+ from domainkit.ledger import ledgers
8
+
9
+ from .settings import EvidenceSettings
10
+
11
+ MESSAGING = "domainkit.contrib.messaging"
12
+
13
+
14
+ class EvidenceConfig(DomainConfig):
15
+ """Check the evidence ledger; register durable recording with messaging."""
16
+
17
+ label = "evidence"
18
+ verbose_name = "Evidence"
19
+ settings_class = EvidenceSettings
20
+
21
+ def ready(self) -> None:
22
+ """Fail fast unless ``EVIDENCE.LEDGER`` names a ledger."""
23
+ ledgers.config(registry.settings(EvidenceSettings).LEDGER)
24
+ if registry.is_installed(MESSAGING):
25
+ import_module(f"{self.name}.tasks")
26
+
27
+ def databases(self, router: DatabaseRouter) -> frozenset[str]:
28
+ """None: evidence lives on a ledger, not in a SQL database."""
29
+ return frozenset()
@@ -0,0 +1 @@
1
+ """Evidence commands."""
@@ -0,0 +1,50 @@
1
+ """``evidence``: list a subject's evidence, or verify one record."""
2
+
3
+ from argparse import ArgumentParser
4
+
5
+ from domainkit.contrib.evidence import EvidenceTampered, evidence_store
6
+ from domainkit.management import BaseCommand, CommandError
7
+
8
+
9
+ class Command(BaseCommand):
10
+ """Read evidence back from the ledger, verified."""
11
+
12
+ help = "List a subject's evidence, or verify one record by key."
13
+
14
+ def add_arguments(self, parser: ArgumentParser) -> None:
15
+ """Accept a subject to list, or a key to verify."""
16
+ mode = parser.add_mutually_exclusive_group(required=True)
17
+ mode.add_argument("--subject", help="list the evidence about this subject")
18
+ mode.add_argument("--verify", metavar="KEY", help="verify the record under KEY")
19
+ parser.add_argument("--limit", type=int, default=50)
20
+
21
+ async def handle(
22
+ self,
23
+ *,
24
+ subject: str | None,
25
+ verify: str | None,
26
+ limit: int,
27
+ **options: object,
28
+ ) -> None:
29
+ """Print one line per record, or the verified record."""
30
+ store = evidence_store()
31
+ if verify is not None:
32
+ recorded = await store.get(verify)
33
+ try:
34
+ await store.verify(recorded.receipt)
35
+ except EvidenceTampered as exc:
36
+ raise CommandError(str(exc)) from exc
37
+ evidence = recorded.evidence
38
+ self.write(
39
+ f"Intact: {evidence.kind} about {evidence.subject} at "
40
+ f"{evidence.recorded_at:%Y-%m-%d %H:%M:%S}, {recorded.receipt.digest}, "
41
+ f"transaction {recorded.receipt.transaction}."
42
+ )
43
+ return
44
+ # The required option group guarantees a subject when not verifying.
45
+ for recorded in await store.for_subject(subject or "", limit=limit):
46
+ state = "intact" if recorded.intact else "WRITTEN AGAIN"
47
+ self.write(
48
+ f"{recorded.receipt.key} {recorded.evidence.kind} "
49
+ f"{recorded.evidence.recorded_at:%Y-%m-%d %H:%M:%S} {state}"
50
+ )