beaver-db 2.2.0__tar.gz → 2.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 (181) hide show
  1. beaver_db-2.3.0/AGENTS.md +143 -0
  2. {beaver_db-2.2.0 → beaver_db-2.3.0}/PKG-INFO +1 -1
  3. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/__init__.py +1 -1
  4. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/cli/discovery.py +1 -1
  5. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/core.py +56 -2
  6. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/docs.py +6 -5
  7. beaver_db-2.3.0/beaver/indexing.py +260 -0
  8. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/logs.py +156 -3
  9. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/manager.py +8 -1
  10. beaver_db-2.3.0/issues/42-uniform-field-indexing-across-collections.md +410 -0
  11. beaver_db-2.3.0/issues/43-singleton-silently-ignores-differing-kwargs-on-a-cache-hit.md +100 -0
  12. beaver_db-2.3.0/issues/44-filters-have-no-cli-or-wire-encoding-so-explain-cannot-be-exposed.md +100 -0
  13. beaver_db-2.3.0/plans/2026-08-01-field-indexing-slices-1-2.md +1654 -0
  14. {beaver_db-2.2.0 → beaver_db-2.3.0}/pyproject.toml +1 -1
  15. beaver_db-2.3.0/tests/unit/test_field_indexing.py +463 -0
  16. {beaver_db-2.2.0 → beaver_db-2.3.0}/uv.lock +1 -1
  17. {beaver_db-2.2.0 → beaver_db-2.3.0}/.dockerignore +0 -0
  18. {beaver_db-2.2.0 → beaver_db-2.3.0}/.github/workflows/ci.yml +0 -0
  19. {beaver_db-2.2.0 → beaver_db-2.3.0}/.github/workflows/release.yaml +0 -0
  20. {beaver_db-2.2.0 → beaver_db-2.3.0}/.github/workflows/tests.yaml +0 -0
  21. {beaver_db-2.2.0 → beaver_db-2.3.0}/.gitignore +0 -0
  22. {beaver_db-2.2.0 → beaver_db-2.3.0}/.python-version +0 -0
  23. {beaver_db-2.2.0 → beaver_db-2.3.0}/.vscode/settings.json +0 -0
  24. {beaver_db-2.2.0 → beaver_db-2.3.0}/CHANGELOG.md +0 -0
  25. {beaver_db-2.2.0 → beaver_db-2.3.0}/LICENSE +0 -0
  26. {beaver_db-2.2.0 → beaver_db-2.3.0}/README.md +0 -0
  27. {beaver_db-2.2.0 → beaver_db-2.3.0}/STATUS.md +0 -0
  28. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/_fracdex.py +0 -0
  29. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/api.py +0 -0
  30. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/blobs.py +0 -0
  31. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/bridge.py +0 -0
  32. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/cache.py +0 -0
  33. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/channels.py +0 -0
  34. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/cli/__init__.py +0 -0
  35. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/cli/main.py +0 -0
  36. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/client.py +0 -0
  37. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/dicts.py +0 -0
  38. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/errors.py +0 -0
  39. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/events.py +0 -0
  40. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/graphs.py +0 -0
  41. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/interfaces.py +0 -0
  42. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/lists.py +0 -0
  43. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/locks.py +0 -0
  44. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/migrate.py +0 -0
  45. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/queries.py +0 -0
  46. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/queues.py +0 -0
  47. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/security.py +0 -0
  48. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/server.py +0 -0
  49. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/sketches.py +0 -0
  50. {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/vectors.py +0 -0
  51. {beaver_db-2.2.0 → beaver_db-2.3.0}/design.md +0 -0
  52. {beaver_db-2.2.0 → beaver_db-2.3.0}/dockerfile +0 -0
  53. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/.gitignore +0 -0
  54. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/_quarto.yml +0 -0
  55. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/dev-architecture.md +0 -0
  56. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/dev-concurrency.md +0 -0
  57. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/dev-contributing.md +0 -0
  58. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/dev-search.md +0 -0
  59. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-blobs.md +0 -0
  60. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-channels.md +0 -0
  61. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-collections.md +0 -0
  62. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-deployment.md +0 -0
  63. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-dicts.md +0 -0
  64. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-lists.md +0 -0
  65. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-locks.md +0 -0
  66. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-logs.md +0 -0
  67. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-queues.md +0 -0
  68. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-sketches.md +0 -0
  69. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide.md +0 -0
  70. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/index.html +0 -0
  71. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/intro.md +0 -0
  72. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/logo.png +0 -0
  73. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/migration-1x-to-2x.md +0 -0
  74. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/migration-rc3-to-rc4-lists.md +0 -0
  75. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/quickstart.md +0 -0
  76. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/superpowers/plans/2026-05-24-list-fracdex-ordering.md +0 -0
  77. {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/superpowers/specs/2026-05-24-list-fracdex-ordering-design.md +0 -0
  78. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/async_pubsub.py +0 -0
  79. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/blobs.py +0 -0
  80. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/cache.py +0 -0
  81. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/fts.py +0 -0
  82. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/fuzzy.py +0 -0
  83. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/general_test.py +0 -0
  84. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/graph.py +0 -0
  85. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/kvstore.py +0 -0
  86. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/list.py +0 -0
  87. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/locks.py +0 -0
  88. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/logs.py +0 -0
  89. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/pqueue.py +0 -0
  90. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/producer_consumer.py +0 -0
  91. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/publisher.py +0 -0
  92. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/pubsub.py +0 -0
  93. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/rerank.py +0 -0
  94. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/stress_vectors.py +0 -0
  95. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/subscriber.py +0 -0
  96. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/textual_chat.css +0 -0
  97. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/textual_chat.py +0 -0
  98. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/type_hints.py +0 -0
  99. {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/vector.py +0 -0
  100. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/15-enhance-cli-with-admin-commands-shell-piping-and-interactivity.md +0 -0
  101. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/18-enhanced-dump-and-load-for-etl.md +0 -0
  102. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/19-add-comprehensive-unit-integration-and-concurrency-test-suite.md +0 -0
  103. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/24-refactor-vectorindex-to-be-a-swappable-strategy-and-add-persistent-lsh-implementation.md +0 -0
  104. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/27-batched-operations-api-batched.md +0 -0
  105. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/28-add-hnsw-vector-strategy-beaver-dbhnsw.md +0 -0
  106. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/36-implement-sid-consumers-dynamic-cli-universal-client-server.md +0 -0
  107. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/37-v2-release-phase-0-audit-and-stabilize.md +0 -0
  108. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/38-v2-release-phase-1-storage-engine.md +0 -0
  109. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/39-v2-release-phase-2-wrappers.md +0 -0
  110. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/40-batched-reentry-deadlock-under-sync-portal-on-large-sequential-writes.md +0 -0
  111. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/41-2x-silently-opens-a-1x-database-and-goes-split-brain.md +0 -0
  112. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/1-refactor-vector-store-to-use-faiss.md +0 -0
  113. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/10-expose-dblock-functionality-on-all-high-level-data-managers.md +0 -0
  114. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/12-add-dump-method-for-json-export-to-all-data-managers.md +0 -0
  115. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/13-adopt-pydantic-deprecate-beavermodel-and-refactor-document-to-be-generic.md +0 -0
  116. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/14-deprecate-fire-based-cli-and-build-a-feature-rich-typer-and-rich-cli.md +0 -0
  117. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/16-add-clear-method-for-all-data-structures.md +0 -0
  118. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/17-replace-atomic-operations-with-custom-beaver-lock.md +0 -0
  119. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/2-comprehensive-async-wrappers.md +0 -0
  120. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/21-implement-dbcache-property-with-wal-invalidation-dummy-fallback-and-performance-stats.md +0 -0
  121. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/22-add-context-manager-support-and-robust-process-wide-cleanup-to-beaverdb.md +0 -0
  122. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/23-global-event-listeners-api.md +0 -0
  123. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/25-refactor-core-to-async-first-architecture-with-sync-bridge.md +0 -0
  124. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/30-add-probabilistic-sketches-approximate-sets-counters.md +0 -0
  125. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/32-secure-dictionaries-encryption-and-hashing.md +0 -0
  126. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/34-composable-graph-algorithms-query-builder-graphrag.md +0 -0
  127. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/35-refactor-core-to-shared-interface-definition-sid-architecture.md +0 -0
  128. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/5-add-dblock-for-inter-process-synchronization.md +0 -0
  129. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/6-drop-in-replacement-for-beaver-rest-server-client.md +0 -0
  130. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/7-replace-faiss-with-simpler-linear-numpy-vectorial-search.md +0 -0
  131. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/8-first-class-synchronization-primitive.md +0 -0
  132. {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/9-type-safe-wrappers-based-on-pydantic-compatible-models.md +0 -0
  133. {beaver_db-2.2.0 → beaver_db-2.3.0}/logo.png +0 -0
  134. {beaver_db-2.2.0 → beaver_db-2.3.0}/makefile +0 -0
  135. {beaver_db-2.2.0 → beaver_db-2.3.0}/pytest.ini +0 -0
  136. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/benchmark_lsh.py +0 -0
  137. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/concurrency/conftest.py +0 -0
  138. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/concurrency/test_concurrency.py +0 -0
  139. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/conftest.py +0 -0
  140. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/integration/__init__.py +0 -0
  141. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/integration/test_server_subprocess.py +0 -0
  142. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/integration/test_sync.py +0 -0
  143. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/perftest.py +0 -0
  144. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/test_docs_search_filters.py +0 -0
  145. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_api_expose.py +0 -0
  146. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_batched.py +0 -0
  147. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_blobs.py +0 -0
  148. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_bridge.py +0 -0
  149. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_channels.py +0 -0
  150. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_cli.py +0 -0
  151. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_client_local_only.py +0 -0
  152. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_client_roundtrip.py +0 -0
  153. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_client_roundtrip_lists.py +0 -0
  154. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_client_roundtrip_logs.py +0 -0
  155. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_client_roundtrip_queues.py +0 -0
  156. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_connect.py +0 -0
  157. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_db_version.py +0 -0
  158. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_dicts.py +0 -0
  159. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_dicts_decorated.py +0 -0
  160. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_docs.py +0 -0
  161. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_errors.py +0 -0
  162. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_events.py +0 -0
  163. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_fracdex.py +0 -0
  164. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_graphs.py +0 -0
  165. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_lists.py +0 -0
  166. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_lists_decorated.py +0 -0
  167. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_lists_fracdex.py +0 -0
  168. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_locks.py +0 -0
  169. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_logs.py +0 -0
  170. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_logs_decorated.py +0 -0
  171. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_lsh.py +0 -0
  172. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_manager_events.py +0 -0
  173. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_migrate.py +0 -0
  174. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_queues.py +0 -0
  175. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_queues_decorated.py +0 -0
  176. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_security.py +0 -0
  177. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_server_auth.py +0 -0
  178. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_server_errors.py +0 -0
  179. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_server_routes.py +0 -0
  180. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_sketchs.py +0 -0
  181. {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_vectors.py +0 -0
@@ -0,0 +1,143 @@
1
+ # AGENTS.md — beaver
2
+
3
+ You're an AI agent picking up **beaver** (`beaver-db` on PyPI). This file is the door.
4
+
5
+ ## What it is
6
+
7
+ A **local-first, embedded, multi-modal database for Python**, built on a single
8
+ SQLite file. One file, many modalities: dicts, lists, queues, logs, documents
9
+ (FTS + fuzzy), vectors (LSH), graphs, blobs, locks, channels and sketches — all
10
+ in the same `.db`, all through one facade.
11
+
12
+ It is a **public library** (`syalia-srl/beaver`, MIT) with real downstream users,
13
+ not an internal utility. Its biggest consumer is AInBox (`repos/ainbox`), where
14
+ warden, magpie, superbot, peacock, help and pipelines all persist through it.
15
+
16
+ Read [`design.md`](design.md) first — the living design document, with the vision
17
+ and the guiding principles. [`STATUS.md`](STATUS.md) is the running inventory of
18
+ what is real versus missing in the v2 cycle.
19
+
20
+ ## The shape of the codebase
21
+
22
+ beaver/core.py the engine: AsyncBeaverDB + the BeaverDB sync facade,
23
+ _create_all_tables, _check_version, every factory method
24
+ beaver/interfaces.py IBeaver* protocols — the surface the SID consumers target
25
+ beaver/api.py @expose / @local_only — how a method reaches CLI + server + client
26
+ beaver/queries.py the q() filter DSL: Query.__getattr__ builds dotted paths → Filter
27
+ beaver/<modality>.py dicts, lists, queues, logs, docs, vectors, graphs, blobs,
28
+ locks, channels, sketches — one module each
29
+ beaver/migrate.py 1.x → 2.x migration
30
+ beaver/cli/ the typer CLI (entry point `beaver`)
31
+ docs/ Quarto site: guide-*.md (users), dev-*.md (contributors)
32
+ issues/ numbered issue/spec files, synced with GitHub
33
+ tests/{unit,integration,concurrency}
34
+
35
+ **Architecture in one paragraph.** `AsyncBeaverDB` is the async core (aiosqlite,
36
+ one connection, WAL). `BeaverDB` is the *synchronous facade*: it spins a reactor
37
+ thread running the async loop and bridges every call to it through
38
+ `BeaverBridge` (the Portal pattern). "Synchronous facade, asynchronous engine" —
39
+ so write the real logic in the async class, and the sync surface follows. See
40
+ `docs/dev-architecture.md` and `docs/dev-concurrency.md`.
41
+
42
+ ## Conventions
43
+
44
+ - **Every table is dunder-named** — `__beaver_logs__`, `__beaver_documents__`,
45
+ … (`core.py:63`). The single-prefix form (`beaver_logs`) is *1.x* and its
46
+ presence means a legacy file; see issue #41.
47
+ - **The `.db` stays a valid, standard SQLite file.** Anyone must be able to open
48
+ it with `sqlite3`. That is a promise from `design.md`, not a preference.
49
+ - **Schema changes are additive.** `_create_all_tables()` runs on *every*
50
+ `connect()` with `CREATE TABLE IF NOT EXISTS`, so a new table simply appears on
51
+ an existing database — no migration. Adding a side table is the house pattern
52
+ (see `__beaver_fts_index__`, `__beaver_trigrams__`, `__beaver_lsh_index__`).
53
+ - **`BEAVER_DB_VERSION` (`core.py:42`) is at 1 and moving it is a breaking
54
+ change.** `_check_version` raises `BeaverIncompatibleSchemaError` when a file's
55
+ `user_version` exceeds what the running beaver understands (`core.py:372`), so
56
+ a bump means databases written by the new release **fail to open on the old
57
+ one**. Prefer a design that fits additively; bump only when you have decided to
58
+ break file compatibility on purpose.
59
+ - **New capability on a manager? Decorate it.** `@expose(path=…, method=…,
60
+ cli_name=…, cli_help=…)` makes it reachable from the CLI, the REST server and
61
+ the remote client at once; `@local_only("reason")` marks what cannot cross the
62
+ wire (infinite streams, file-level operations). A method with neither is
63
+ local-only *by accident* rather than by decision.
64
+ - **Minimal dependencies is a product constraint.** Core is `aiosqlite`, `numpy`,
65
+ `pydantic`, `rich`, `typer`. Heavy things go behind extras (`remote`,
66
+ `security`). In particular, **vector indexing stays pure-numpy** — no
67
+ `hnswlib`, no `faiss`, no compiled deps. The LSH strategy exists precisely so
68
+ scaling did not require one (issue #24); HNSW (#28) stays deferred until a
69
+ pure-numpy ANN beats LSH at >100k vectors.
70
+ - **Silence is the bug.** The lesson of issue #41 — beaver opened a 1.x file,
71
+ reported everything empty, and wrote a parallel schema beside the untouched
72
+ data. Nothing errored; that was the problem. When state is partial, stale or
73
+ unmigrated, **say so loudly or degrade to the correct-but-slower path** — never
74
+ return a plausible subset.
75
+ - Ships to `main`. English for code, docs, comments and commit messages.
76
+
77
+ ## Working here
78
+
79
+ make sync uv sync --extra security --extra remote
80
+ uv run black . format (see the warning below)
81
+ make format-check black --check — test-unit depends on this
82
+ make type-check mypy
83
+ make test-unit pytest tests/unit --cov=beaver ← the default target
84
+ make test-all every suite, including concurrency
85
+ make ci format-check + test-unit
86
+
87
+ `pytest.ini` sets `asyncio_mode = auto` (no `@pytest.mark.asyncio` needed) and a
88
+ **30 s timeout** — a test that hangs on a lock fails rather than wedging the run.
89
+ Markers: `unit`, `integration`, `concurrency`.
90
+
91
+ > ⚠️ **`make format` and `make bugfix` commit for you**, and they do it with
92
+ > `git commit -am` / `git commit -a`, which sweeps up **every dirty file in the
93
+ > tree** — including work from a concurrent session that has nothing to do with
94
+ > yours. Use **`uv run black .`** and commit the paths you touched yourself.
95
+ > `make format-check` is safe and is what `test-unit` actually gates on.
96
+
97
+ **Releases:** `NEW_VERSION=x.y.z make release` — runs `test-all`, bumps the
98
+ version in `pyproject.toml` *and* `beaver/__init__.py`, commits, tags, pushes and
99
+ opens the GitHub release. Don't hand-edit those two version strings.
100
+
101
+ **Issues are files.** `issues/NN-slug.md` with `number / title / state / labels`
102
+ frontmatter, round-tripped to GitHub by `make issues` (needs the `gh md-issues`
103
+ extension). A substantial feature gets an issue file written *first* — see
104
+ [`issues/24-…`](issues/) (LSH strategy) and
105
+ [`issues/42-uniform-field-indexing-across-collections.md`](issues/42-uniform-field-indexing-across-collections.md)
106
+ (field indexing) for the shape: Summary → the problem with real line references →
107
+ Design → costs → delivery order → testing.
108
+
109
+ ## Gotchas paid for in advance
110
+
111
+ - **FTS5 rejects punctuation in the query string** — tokenize before you hand a
112
+ user's words to `.search()` / `.fts()`. Measured on 2.2.0 against an indexed
113
+ document, three different `OperationalError`s:
114
+
115
+ | query | error |
116
+ |---|---|
117
+ | `thing` | — (1 hit) |
118
+ | `what's this?` | `fts5: syntax error near "'"` |
119
+ | `this?` | `fts5: syntax error near "?"` |
120
+ | `a-b` | `no such column: b` |
121
+
122
+ The hyphen case is the trap: FTS5 reads `a-b` as a **column filter**, so you
123
+ get a "no such column" error that says nothing about punctuation and sends you
124
+ hunting for a schema bug that isn't there.
125
+ - **There is no reranking.** `docs` gives you FTS rank and trigram counts;
126
+ combining lexical and vector results is the caller's job (roll your own RRF).
127
+ - **Embeddings are not part of `docs`.** A `docs()` collection and a `vectors()`
128
+ collection are separate things you keep in sync yourself; `docs` never embeds.
129
+ - **`docs().where()` is a full scan today** — it compiles to `json_extract` per
130
+ row (`docs.py:472`), which is fast but not an index. Issue #42 is the fix.
131
+ - **`__beaver_priority_queues__` has no declared PRIMARY KEY.** Its implicit
132
+ `rowid` is the only stable per-item identity. Any code assuming every table
133
+ declares a PK is wrong.
134
+ - **`log()` resolves timestamp collisions by micro-incrementing and retrying**
135
+ (`logs.py:96`). Under many concurrent writers that loop can spin — use
136
+ `batched()`, which enforces monotonicity up front.
137
+
138
+ ## Where the planning lives
139
+
140
+ Design docs and the v2 release plan live in Alex's workspace vault
141
+ (`vault/Atlas/Architecture/2026-05-15-beaver-v2-release-plan.md`), not in this
142
+ repo — this repo carries `design.md`, `STATUS.md` and `issues/`. If you need the
143
+ wider roadmap and cannot see the vault, ask rather than guess.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: beaver-db
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Fast, async-native, embedded, and multi-modal DB based on SQLite for AI-powered applications.
5
5
  License-File: LICENSE
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -10,7 +10,7 @@ from .queries import q
10
10
  from .security import Secret
11
11
  from .errors import BeaverRemoteError, LocalOnlyError
12
12
 
13
- __version__ = "2.2.0"
13
+ __version__ = "2.3.0"
14
14
 
15
15
 
16
16
  def connect(source: str, *, sync: bool = True, api_key: str | None = None):
@@ -171,7 +171,7 @@ def _build_command(method_name: str, meta: EndpointMeta, manager_accessor: Calla
171
171
  _run(ctx, manager_accessor, method_name, block=block, timeout=timeout)
172
172
 
173
173
  # --- shared no-arg shapes ---
174
- elif method_name in ("count", "clear"):
174
+ elif method_name in ("count", "clear", "indexes"):
175
175
 
176
176
  def cmd(ctx: typer.Context):
177
177
  _run(ctx, manager_accessor, method_name)
@@ -484,6 +484,41 @@ class AsyncBeaverDB:
484
484
  "CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON __beaver_logs__ (log_name, timestamp)"
485
485
  )
486
486
 
487
+ # Field Index (issue #42) — uniform per-field indexing.
488
+ # Additive by design: new tables only, no ALTER, no version bump.
489
+ await c.execute(
490
+ """
491
+ CREATE TABLE IF NOT EXISTS __beaver_field_index__ (
492
+ kind TEXT NOT NULL,
493
+ name TEXT NOT NULL,
494
+ item_key TEXT NOT NULL,
495
+ field TEXT NOT NULL,
496
+ value TEXT,
497
+ value_num REAL,
498
+ PRIMARY KEY (kind, name, item_key, field)
499
+ )
500
+ """
501
+ )
502
+ await c.execute(
503
+ "CREATE INDEX IF NOT EXISTS idx_field_lookup "
504
+ "ON __beaver_field_index__ (kind, name, field, value)"
505
+ )
506
+ await c.execute(
507
+ "CREATE INDEX IF NOT EXISTS idx_field_lookup_num "
508
+ "ON __beaver_field_index__ (kind, name, field, value_num)"
509
+ )
510
+ await c.execute(
511
+ """
512
+ CREATE TABLE IF NOT EXISTS __beaver_field_index_manifest__ (
513
+ kind TEXT NOT NULL,
514
+ name TEXT NOT NULL,
515
+ field TEXT NOT NULL,
516
+ complete INTEGER NOT NULL DEFAULT 0,
517
+ PRIMARY KEY (kind, name, field)
518
+ )
519
+ """
520
+ )
521
+
487
522
  # Priority Queues
488
523
  await c.execute(
489
524
  """
@@ -685,9 +720,28 @@ class AsyncBeaverDB:
685
720
  return self.singleton(AsyncBeaverBlob, name, model=model)
686
721
 
687
722
  def log[T: BaseModel](
688
- self, name: str, model: type[T] | None = None
723
+ self,
724
+ name: str,
725
+ model: type[T] | None = None,
726
+ # Quoted: inside a PEP 695 generic scope the class body is visible, so
727
+ # a bare `list[str]` resolves to AsyncBeaverDB.list, not the builtin.
728
+ indexed: "list[str] | None" = None,
689
729
  ) -> AsyncBeaverLog[T]:
690
- return self.singleton(AsyncBeaverLog, name, model=model)
730
+ """Open a log.
731
+
732
+ `indexed` declares the fields whose values are maintained in the field
733
+ index. **Declare on first use:** `singleton()` keys its cache on
734
+ `(cls, name)` only, so after a bare `db.log("audit")` a later
735
+ `db.log("audit", indexed=[...])` returns the already-cached, undeclared
736
+ manager and the declaration is silently dropped.
737
+ """
738
+ return self.singleton(AsyncBeaverLog, name, model=model, indexed=indexed)
739
+
740
+ async def indexes(self) -> dict:
741
+ """Every declared field index in this database, by (kind, name)."""
742
+ from . import indexing
743
+
744
+ return await indexing.all_field_indexes(self.connection)
691
745
 
692
746
  def lock(
693
747
  self, name: str, timeout=None, lock_ttl=60.0, poll_interval=0.1
@@ -11,6 +11,7 @@ from typing import (
11
11
 
12
12
  from pydantic import BaseModel
13
13
 
14
+ from . import indexing
14
15
  from .queries import Filter
15
16
  from .manager import AsyncBeaverBase, atomic, emits
16
17
  from .interfaces import Document, ScoredDocument, IDocumentQuery
@@ -468,11 +469,11 @@ class AsyncBeaverDocuments[T: BaseModel](AsyncBeaverBase[T]):
468
469
  params.extend(q._search_fields)
469
470
 
470
471
  if q._filters:
471
- for filter in q._filters:
472
- where.append(
473
- f"json_extract(d.data, '$.{filter.path}') {filter.operator} ?"
474
- )
475
- params.append(filter.value)
472
+ clauses, filter_params = indexing.compile_scan_filters(
473
+ "data", q._filters, alias="d"
474
+ )
475
+ where.extend(clauses)
476
+ params.extend(filter_params)
476
477
 
477
478
  parts.append("WHERE " + " AND ".join(where))
478
479
 
@@ -0,0 +1,260 @@
1
+ """Uniform per-field indexing across collections (issue #42).
2
+
3
+ Owns the whole substrate: dotted-path extraction, scalar normalization, the
4
+ completeness manifest, index-row maintenance, and filter -> SQL compilation.
5
+ Knows nothing about any specific collection; callers pass their `kind`, `name`
6
+ and per-item key.
7
+ """
8
+
9
+ from dataclasses import dataclass, field as _dc_field
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel
13
+
14
+ from .queries import Filter
15
+
16
+ INDEX_TABLE = "__beaver_field_index__"
17
+ MANIFEST_TABLE = "__beaver_field_index_manifest__"
18
+
19
+ _MISSING = object()
20
+
21
+
22
+ @dataclass
23
+ class FieldIndex:
24
+ """The state of one declared field.
25
+
26
+ `declared` says the write path maintains it; `complete` says the read path
27
+ may trust it. They are different questions and both matter.
28
+ """
29
+
30
+ field: str
31
+ declared: bool
32
+ complete: bool
33
+ rows: int
34
+
35
+
36
+ @dataclass
37
+ class QueryPlan:
38
+ """Which filters resolved by index and which fell back to a scan."""
39
+
40
+ indexed: list[str] = _dc_field(default_factory=list)
41
+ scanned: list[str] = _dc_field(default_factory=list)
42
+ estimated_rows: int = 0
43
+
44
+
45
+ class UnindexableFieldError(TypeError):
46
+ """Raised when a declared field holds a non-scalar value."""
47
+
48
+
49
+ def extract_path(item: Any, path: str) -> tuple[bool, Any]:
50
+ """Walk a dotted path through models and dicts.
51
+
52
+ Returns ``(found, value)``. ``found`` is False when any segment is absent
53
+ or the path runs through a non-traversable value — distinguishing "field
54
+ absent" from "field present and None".
55
+ """
56
+ current: Any = item
57
+ for part in path.split("."):
58
+ if isinstance(current, BaseModel):
59
+ current = getattr(current, part, _MISSING)
60
+ elif isinstance(current, dict):
61
+ current = current.get(part, _MISSING)
62
+ else:
63
+ return False, None
64
+ if current is _MISSING:
65
+ return False, None
66
+ return True, current
67
+
68
+
69
+ def normalize(value: Any) -> tuple[str | None, float | None]:
70
+ """Split a scalar into its (text, numeric) index forms.
71
+
72
+ ``value_num`` is what makes ``duration_ms > 1000`` exclude 900; without it
73
+ the comparison is lexicographic and "900" > "1000".
74
+ """
75
+ if value is None:
76
+ return None, None
77
+ if isinstance(value, bool):
78
+ return str(value), float(value)
79
+ if isinstance(value, (int, float)):
80
+ return str(value), float(value)
81
+ if isinstance(value, str):
82
+ return value, None
83
+ raise UnindexableFieldError(
84
+ f"Cannot index a value of type {type(value).__name__}; "
85
+ "only str, int, float, bool and None are indexable."
86
+ )
87
+
88
+
89
+ async def declare(conn, kind: str, name: str, fields: list[str]) -> None:
90
+ """Record fields as declared. Idempotent, and never downgrades `complete`.
91
+
92
+ Re-declaring an already-complete field must not reset it, or every process
93
+ restart would silently drop the collection back to scanning.
94
+ """
95
+ await conn.executemany(
96
+ f"INSERT OR IGNORE INTO {MANIFEST_TABLE} (kind, name, field, complete) "
97
+ "VALUES (?, ?, ?, 0)",
98
+ [(kind, name, f) for f in fields],
99
+ )
100
+
101
+
102
+ async def manifest(conn, kind: str, name: str) -> dict[str, bool]:
103
+ """Map declared field -> whether the read path may trust its index."""
104
+ cursor = await conn.execute(
105
+ f"SELECT field, complete FROM {MANIFEST_TABLE} WHERE kind = ? AND name = ?",
106
+ (kind, name),
107
+ )
108
+ return {row[0]: bool(row[1]) for row in await cursor.fetchall()}
109
+
110
+
111
+ async def mark_complete(conn, kind: str, name: str, fields: list[str]) -> None:
112
+ """Mark fields as fully backfilled, so queries may use the index."""
113
+ await conn.executemany(
114
+ f"UPDATE {MANIFEST_TABLE} SET complete = 1 "
115
+ "WHERE kind = ? AND name = ? AND field = ?",
116
+ [(kind, name, f) for f in fields],
117
+ )
118
+
119
+
120
+ def build_rows(
121
+ kind: str, name: str, item_key: str, item: Any, fields: list[str]
122
+ ) -> list[tuple]:
123
+ """The index rows for one item. Absent fields produce no row."""
124
+ rows: list[tuple] = []
125
+ for field in fields:
126
+ found, raw = extract_path(item, field)
127
+ if not found:
128
+ continue
129
+ value, value_num = normalize(raw)
130
+ rows.append((kind, name, item_key, field, value, value_num))
131
+ return rows
132
+
133
+
134
+ async def index_item(
135
+ conn, kind: str, name: str, item_key: str, item: Any, fields: list[str]
136
+ ) -> None:
137
+ """Replace this item's index rows. Delete-then-insert so an overwrite
138
+ cannot leave a stale row behind for a field that is now absent."""
139
+ await unindex_item(conn, kind, name, item_key)
140
+ rows = build_rows(kind, name, item_key, item, fields)
141
+ if rows:
142
+ await conn.executemany(
143
+ f"INSERT INTO {INDEX_TABLE} "
144
+ "(kind, name, item_key, field, value, value_num) "
145
+ "VALUES (?, ?, ?, ?, ?, ?)",
146
+ rows,
147
+ )
148
+
149
+
150
+ async def unindex_item(conn, kind: str, name: str, item_key: str) -> None:
151
+ await conn.execute(
152
+ f"DELETE FROM {INDEX_TABLE} WHERE kind = ? AND name = ? AND item_key = ?",
153
+ (kind, name, item_key),
154
+ )
155
+
156
+
157
+ async def clear_index(conn, kind: str, name: str) -> None:
158
+ await conn.execute(
159
+ f"DELETE FROM {INDEX_TABLE} WHERE kind = ? AND name = ?", (kind, name)
160
+ )
161
+
162
+
163
+ async def index_rows(conn, kind: str, name: str) -> int:
164
+ cursor = await conn.execute(
165
+ f"SELECT COUNT(*) FROM {INDEX_TABLE} WHERE kind = ? AND name = ?",
166
+ (kind, name),
167
+ )
168
+ row = await cursor.fetchone()
169
+ return row[0] if row else 0
170
+
171
+
172
+ def compile_scan_filters(
173
+ column: str, filters: list[Filter], alias: str = "d"
174
+ ) -> tuple[list[str], list]:
175
+ """Compile filters to json_extract predicates — the unindexed fallback.
176
+
177
+ Correct for any field, indexed or not, which is what lets an incomplete
178
+ index degrade to something slower instead of something wrong.
179
+ """
180
+ clauses: list[str] = []
181
+ params: list = []
182
+ for f in filters:
183
+ clauses.append(f"json_extract({alias}.{column}, '$.{f.path}') {f.operator} ?")
184
+ params.append(f.value)
185
+ return clauses, params
186
+
187
+
188
+ _NUMERIC_OPS = {">", ">=", "<", "<="}
189
+
190
+
191
+ def compile_indexed_filter(
192
+ kind: str, name: str, f: Filter, key_expr: str
193
+ ) -> tuple[str, list]:
194
+ """One filter as a membership test against the field index.
195
+
196
+ ``key_expr`` is how the outer table's row identity is written in SQL —
197
+ for logs, ``timestamp``, compared against ``CAST(item_key AS REAL)``.
198
+ """
199
+ column = "value_num" if f.operator in _NUMERIC_OPS else "value"
200
+ value = float(f.value) if f.operator in _NUMERIC_OPS else f.value
201
+ sql = (
202
+ f"{key_expr} IN (SELECT CAST(item_key AS REAL) FROM {INDEX_TABLE} "
203
+ f"WHERE kind = ? AND name = ? AND field = ? AND {column} {f.operator} ?)"
204
+ )
205
+ return sql, [kind, name, f.path, value]
206
+
207
+
208
+ def plan_filters(
209
+ kind: str,
210
+ name: str,
211
+ filters: list[Filter],
212
+ complete: dict[str, bool],
213
+ key_expr: str,
214
+ column: str,
215
+ alias: str,
216
+ ) -> tuple[list[str], list, list[str], list[str]]:
217
+ """Split filters into indexed and scanned, and compile both.
218
+
219
+ Returns ``(clauses, params, indexed_paths, scanned_paths)``. A field is only
220
+ routed to the index when the manifest says it is complete — an unbackfilled
221
+ index would answer with a subset and say nothing about it.
222
+ """
223
+ clauses: list[str] = []
224
+ params: list = []
225
+ indexed: list[str] = []
226
+ scanned: list[str] = []
227
+ for f in filters:
228
+ if complete.get(f.path):
229
+ sql, ps = compile_indexed_filter(kind, name, f, key_expr)
230
+ clauses.append(sql)
231
+ params.extend(ps)
232
+ indexed.append(f.path)
233
+ else:
234
+ sql_list, ps = compile_scan_filters(column, [f], alias=alias)
235
+ clauses.extend(sql_list)
236
+ params.extend(ps)
237
+ scanned.append(f.path)
238
+ return clauses, params, indexed, scanned
239
+
240
+
241
+ async def field_indexes(conn, kind: str, name: str) -> list[FieldIndex]:
242
+ declared = await manifest(conn, kind, name)
243
+ cursor = await conn.execute(
244
+ f"SELECT field, COUNT(*) FROM {INDEX_TABLE} "
245
+ "WHERE kind = ? AND name = ? GROUP BY field",
246
+ (kind, name),
247
+ )
248
+ counts = {row[0]: row[1] for row in await cursor.fetchall()}
249
+ return [
250
+ FieldIndex(field=f, declared=True, complete=c, rows=counts.get(f, 0))
251
+ for f, c in sorted(declared.items())
252
+ ]
253
+
254
+
255
+ async def all_field_indexes(conn) -> dict[tuple[str, str], list[FieldIndex]]:
256
+ cursor = await conn.execute(
257
+ f"SELECT DISTINCT kind, name FROM {MANIFEST_TABLE} ORDER BY kind, name"
258
+ )
259
+ pairs = [(r[0], r[1]) for r in await cursor.fetchall()]
260
+ return {(k, n): await field_indexes(conn, k, n) for k, n in pairs}