beaver-db 2.2.0__tar.gz → 2.4.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.
- beaver_db-2.4.0/AGENTS.md +166 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/CHANGELOG.md +51 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/PKG-INFO +1 -1
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/__init__.py +3 -1
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/cli/discovery.py +1 -1
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/core.py +191 -12
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/docs.py +6 -5
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/errors.py +6 -1
- beaver_db-2.4.0/beaver/indexing.py +260 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/logs.py +156 -3
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/manager.py +8 -1
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/dev-concurrency.md +28 -0
- beaver_db-2.4.0/issues/42-uniform-field-indexing-across-collections.md +410 -0
- beaver_db-2.4.0/issues/43-singleton-silently-ignores-differing-kwargs-on-a-cache-hit.md +100 -0
- beaver_db-2.4.0/issues/44-filters-have-no-cli-or-wire-encoding-so-explain-cannot-be-exposed.md +100 -0
- beaver_db-2.4.0/plans/2026-08-01-field-indexing-slices-1-2.md +1654 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/pyproject.toml +1 -1
- beaver_db-2.4.0/tests/unit/test_field_indexing.py +463 -0
- beaver_db-2.4.0/tests/unit/test_transactions.py +299 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/uv.lock +1 -1
- {beaver_db-2.2.0 → beaver_db-2.4.0}/.dockerignore +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/.github/workflows/ci.yml +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/.github/workflows/release.yaml +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/.github/workflows/tests.yaml +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/.gitignore +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/.python-version +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/.vscode/settings.json +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/LICENSE +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/README.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/STATUS.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/_fracdex.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/api.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/blobs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/bridge.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/cache.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/channels.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/cli/__init__.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/cli/main.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/client.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/dicts.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/events.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/graphs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/interfaces.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/lists.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/locks.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/migrate.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/queries.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/queues.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/security.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/server.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/sketches.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/beaver/vectors.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/design.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/dockerfile +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/.gitignore +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/_quarto.yml +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/dev-architecture.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/dev-contributing.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/dev-search.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/guide-blobs.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/guide-channels.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/guide-collections.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/guide-deployment.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/guide-dicts.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/guide-lists.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/guide-locks.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/guide-logs.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/guide-queues.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/guide-sketches.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/guide.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/index.html +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/intro.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/logo.png +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/migration-1x-to-2x.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/migration-rc3-to-rc4-lists.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/quickstart.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/superpowers/plans/2026-05-24-list-fracdex-ordering.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/docs/superpowers/specs/2026-05-24-list-fracdex-ordering-design.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/async_pubsub.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/blobs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/cache.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/fts.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/fuzzy.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/general_test.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/graph.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/kvstore.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/list.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/locks.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/logs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/pqueue.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/producer_consumer.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/publisher.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/pubsub.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/rerank.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/stress_vectors.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/subscriber.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/textual_chat.css +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/textual_chat.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/type_hints.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/examples/vector.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/15-enhance-cli-with-admin-commands-shell-piping-and-interactivity.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/18-enhanced-dump-and-load-for-etl.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/19-add-comprehensive-unit-integration-and-concurrency-test-suite.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/24-refactor-vectorindex-to-be-a-swappable-strategy-and-add-persistent-lsh-implementation.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/27-batched-operations-api-batched.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/28-add-hnsw-vector-strategy-beaver-dbhnsw.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/36-implement-sid-consumers-dynamic-cli-universal-client-server.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/37-v2-release-phase-0-audit-and-stabilize.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/38-v2-release-phase-1-storage-engine.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/39-v2-release-phase-2-wrappers.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/40-batched-reentry-deadlock-under-sync-portal-on-large-sequential-writes.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/41-2x-silently-opens-a-1x-database-and-goes-split-brain.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/1-refactor-vector-store-to-use-faiss.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/10-expose-dblock-functionality-on-all-high-level-data-managers.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/12-add-dump-method-for-json-export-to-all-data-managers.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/13-adopt-pydantic-deprecate-beavermodel-and-refactor-document-to-be-generic.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/14-deprecate-fire-based-cli-and-build-a-feature-rich-typer-and-rich-cli.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/16-add-clear-method-for-all-data-structures.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/17-replace-atomic-operations-with-custom-beaver-lock.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/2-comprehensive-async-wrappers.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/21-implement-dbcache-property-with-wal-invalidation-dummy-fallback-and-performance-stats.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/22-add-context-manager-support-and-robust-process-wide-cleanup-to-beaverdb.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/23-global-event-listeners-api.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/25-refactor-core-to-async-first-architecture-with-sync-bridge.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/30-add-probabilistic-sketches-approximate-sets-counters.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/32-secure-dictionaries-encryption-and-hashing.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/34-composable-graph-algorithms-query-builder-graphrag.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/35-refactor-core-to-shared-interface-definition-sid-architecture.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/5-add-dblock-for-inter-process-synchronization.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/6-drop-in-replacement-for-beaver-rest-server-client.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/7-replace-faiss-with-simpler-linear-numpy-vectorial-search.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/8-first-class-synchronization-primitive.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/issues/closed/9-type-safe-wrappers-based-on-pydantic-compatible-models.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/logo.png +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/makefile +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/pytest.ini +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/benchmark_lsh.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/concurrency/conftest.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/concurrency/test_concurrency.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/conftest.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/integration/__init__.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/integration/test_server_subprocess.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/integration/test_sync.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/perftest.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/test_docs_search_filters.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_api_expose.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_batched.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_blobs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_bridge.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_channels.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_cli.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_client_local_only.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_client_roundtrip.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_client_roundtrip_lists.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_client_roundtrip_logs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_client_roundtrip_queues.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_connect.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_db_version.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_dicts.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_dicts_decorated.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_docs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_errors.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_events.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_fracdex.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_graphs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_lists.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_lists_decorated.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_lists_fracdex.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_locks.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_logs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_logs_decorated.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_lsh.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_manager_events.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_migrate.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_queues.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_queues_decorated.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_security.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_server_auth.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_server_errors.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_server_routes.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_sketchs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.4.0}/tests/unit/test_vectors.py +0 -0
|
@@ -0,0 +1,166 @@
|
|
|
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
|
+
The GitHub release is what actually ships it: `.github/workflows/release.yaml`
|
|
102
|
+
fires on `release: created`, re-runs the suite, publishes to PyPI with
|
|
103
|
+
`secrets.PYPI_TOKEN`, then pushes the image to GHCR. A tag alone publishes
|
|
104
|
+
nothing.
|
|
105
|
+
|
|
106
|
+
**Verify against an install, not against the tag — and not against the JSON API
|
|
107
|
+
either.** Two things bite here, both observed releasing 2.3.0 on 2026-08-01:
|
|
108
|
+
|
|
109
|
+
- `https://pypi.org/pypi/<pkg>/<ver>/json` returned **200** while `uv pip install`
|
|
110
|
+
still could not resolve the version. The JSON API and the simple index that
|
|
111
|
+
installers actually read propagate separately, so a 200 there is a proxy
|
|
112
|
+
signal, not proof the package is installable.
|
|
113
|
+
- `uv` caches the index, and **`--refresh` does not clear it** — the install kept
|
|
114
|
+
failing with *"there is no version of beaver-db==2.3.0"* long after the simple
|
|
115
|
+
index listed both artifacts. `--no-cache` is what works.
|
|
116
|
+
|
|
117
|
+
So the real check is a scratch venv that exercises the feature:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
uv venv /tmp/verify && VIRTUAL_ENV=/tmp/verify uv pip install --no-cache "beaver-db==X.Y.Z"
|
|
121
|
+
VIRTUAL_ENV=/tmp/verify uv run --no-project python -c "…exercise the new API…"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Issues are files.** `issues/NN-slug.md` with `number / title / state / labels`
|
|
125
|
+
frontmatter, round-tripped to GitHub by `make issues` (needs the `gh md-issues`
|
|
126
|
+
extension). A substantial feature gets an issue file written *first* — see
|
|
127
|
+
[`issues/24-…`](issues/) (LSH strategy) and
|
|
128
|
+
[`issues/42-uniform-field-indexing-across-collections.md`](issues/42-uniform-field-indexing-across-collections.md)
|
|
129
|
+
(field indexing) for the shape: Summary → the problem with real line references →
|
|
130
|
+
Design → costs → delivery order → testing.
|
|
131
|
+
|
|
132
|
+
## Gotchas paid for in advance
|
|
133
|
+
|
|
134
|
+
- **FTS5 rejects punctuation in the query string** — tokenize before you hand a
|
|
135
|
+
user's words to `.search()` / `.fts()`. Measured on 2.2.0 against an indexed
|
|
136
|
+
document, three different `OperationalError`s:
|
|
137
|
+
|
|
138
|
+
| query | error |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `thing` | — (1 hit) |
|
|
141
|
+
| `what's this?` | `fts5: syntax error near "'"` |
|
|
142
|
+
| `this?` | `fts5: syntax error near "?"` |
|
|
143
|
+
| `a-b` | `no such column: b` |
|
|
144
|
+
|
|
145
|
+
The hyphen case is the trap: FTS5 reads `a-b` as a **column filter**, so you
|
|
146
|
+
get a "no such column" error that says nothing about punctuation and sends you
|
|
147
|
+
hunting for a schema bug that isn't there.
|
|
148
|
+
- **There is no reranking.** `docs` gives you FTS rank and trigram counts;
|
|
149
|
+
combining lexical and vector results is the caller's job (roll your own RRF).
|
|
150
|
+
- **Embeddings are not part of `docs`.** A `docs()` collection and a `vectors()`
|
|
151
|
+
collection are separate things you keep in sync yourself; `docs` never embeds.
|
|
152
|
+
- **`docs().where()` is a full scan today** — it compiles to `json_extract` per
|
|
153
|
+
row (`docs.py:472`), which is fast but not an index. Issue #42 is the fix.
|
|
154
|
+
- **`__beaver_priority_queues__` has no declared PRIMARY KEY.** Its implicit
|
|
155
|
+
`rowid` is the only stable per-item identity. Any code assuming every table
|
|
156
|
+
declares a PK is wrong.
|
|
157
|
+
- **`log()` resolves timestamp collisions by micro-incrementing and retrying**
|
|
158
|
+
(`logs.py:96`). Under many concurrent writers that loop can spin — use
|
|
159
|
+
`batched()`, which enforces monotonicity up front.
|
|
160
|
+
|
|
161
|
+
## Where the planning lives
|
|
162
|
+
|
|
163
|
+
Design docs and the v2 release plan live in Alex's workspace vault
|
|
164
|
+
(`vault/Atlas/Architecture/2026-05-15-beaver-v2-release-plan.md`), not in this
|
|
165
|
+
repo — this repo carries `design.md`, `STATUS.md` and `issues/`. If you need the
|
|
166
|
+
wider roadmap and cannot see the vault, ask rather than guess.
|
|
@@ -2,6 +2,57 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to beaver-db will be recorded here.
|
|
4
4
|
|
|
5
|
+
## 2.4.0 — 2026-08-10
|
|
6
|
+
|
|
7
|
+
### Fixed — availability class
|
|
8
|
+
|
|
9
|
+
- **A cancelled transaction no longer deadlocks every writer.** `Transaction.
|
|
10
|
+
__aenter__` had two windows in which it could leave `_tx_lock` held with
|
|
11
|
+
`__aexit__` never running, because the `async with` body was never entered:
|
|
12
|
+
cancellation between acquiring the lock and returning, and `BEGIN IMMEDIATE`
|
|
13
|
+
raising. Both left the lock held forever; the cancellation case *also* left
|
|
14
|
+
SQLite's write lock held, because cancelling the wait on
|
|
15
|
+
`connection.execute("BEGIN IMMEDIATE")` does not un-run the statement on
|
|
16
|
+
aiosqlite's worker thread.
|
|
17
|
+
|
|
18
|
+
In WAL mode this is nearly invisible: readers keep working while every
|
|
19
|
+
writer queues behind a lock with no owner. It took down authentication on an
|
|
20
|
+
AInBox appliance for hours on 2026-08-10 while the process kept serving its
|
|
21
|
+
login page and passing its healthcheck. Starlette/uvicorn cancel a request
|
|
22
|
+
task whenever a client disconnects, so the trigger is ordinary.
|
|
23
|
+
|
|
24
|
+
The enter path now cleans up after itself: a failed or cancelled `BEGIN` is
|
|
25
|
+
rolled back (shielded, so it survives the cancellation that caused it) and
|
|
26
|
+
the lock is released in a `finally`.
|
|
27
|
+
|
|
28
|
+
- **Ownership is claimed only once the transaction has actually begun.**
|
|
29
|
+
`_tx_owner_task` was set *before* `BEGIN IMMEDIATE`, so a raising BEGIN left
|
|
30
|
+
it naming a transaction that never started — and the reentrancy check then
|
|
31
|
+
waved that task's next transaction through as a silent no-op, so a rolled
|
|
32
|
+
back block would persist.
|
|
33
|
+
|
|
34
|
+
- **The reentrancy check no longer matches outside a task.**
|
|
35
|
+
`_tx_owner_task == current_task` was true when both were `None`, which
|
|
36
|
+
reads a caller with no running task as already owning the transaction.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **`TransactionTimeoutError`** (exported from `beaver`, subclass of
|
|
41
|
+
`TimeoutError`). Waiting for the write lock is now bounded by
|
|
42
|
+
`connection_timeout` (default 30 s) instead of being unbounded. beaver
|
|
43
|
+
multiplexes every modality over a single connection, so contending tasks
|
|
44
|
+
queue on an in-process `asyncio.Lock` that SQLite's busy-timeout — which
|
|
45
|
+
only arbitrates between *separate* connections — never gets a say over.
|
|
46
|
+
An unbounded wait there is indistinguishable from a hang.
|
|
47
|
+
|
|
48
|
+
- **`db.transaction(timeout=…)`** overrides that bound per call, for callers
|
|
49
|
+
with their own latency budget (a healthcheck, a request handler).
|
|
50
|
+
|
|
51
|
+
- **An orphaned transaction is reclaimed, not waited on.** If the lock's owner
|
|
52
|
+
is a task that has already finished, the next writer rolls its transaction
|
|
53
|
+
back, logs a warning on the `beaver` logger and takes over. Recovery is
|
|
54
|
+
loud on purpose: it means something leaked.
|
|
55
|
+
|
|
5
56
|
## 2.2.0 — 2026-07-29
|
|
6
57
|
|
|
7
58
|
### Fixed — data-loss class (#41)
|
|
@@ -3,6 +3,7 @@ from .core import (
|
|
|
3
3
|
AsyncBeaverDB,
|
|
4
4
|
BeaverIncompatibleSchemaError,
|
|
5
5
|
BeaverLegacySchemaError,
|
|
6
|
+
TransactionTimeoutError,
|
|
6
7
|
)
|
|
7
8
|
from .docs import Document
|
|
8
9
|
from .events import Event
|
|
@@ -10,7 +11,7 @@ from .queries import q
|
|
|
10
11
|
from .security import Secret
|
|
11
12
|
from .errors import BeaverRemoteError, LocalOnlyError
|
|
12
13
|
|
|
13
|
-
__version__ = "2.
|
|
14
|
+
__version__ = "2.4.0"
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def connect(source: str, *, sync: bool = True, api_key: str | None = None):
|
|
@@ -39,6 +40,7 @@ __all__ = [
|
|
|
39
40
|
"BeaverDB",
|
|
40
41
|
"BeaverIncompatibleSchemaError",
|
|
41
42
|
"BeaverLegacySchemaError",
|
|
43
|
+
"TransactionTimeoutError",
|
|
42
44
|
"Document",
|
|
43
45
|
"Secret",
|
|
44
46
|
"Event",
|
|
@@ -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)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import json
|
|
3
|
+
import logging
|
|
3
4
|
import threading
|
|
4
5
|
from typing import Any, Self, Type, AsyncContextManager
|
|
5
6
|
|
|
@@ -41,6 +42,8 @@ from .vectors import AsyncBeaverVectors
|
|
|
41
42
|
|
|
42
43
|
BEAVER_DB_VERSION = 1
|
|
43
44
|
|
|
45
|
+
_log = logging.getLogger("beaver")
|
|
46
|
+
|
|
44
47
|
|
|
45
48
|
class BeaverIncompatibleSchemaError(RuntimeError):
|
|
46
49
|
"""Raised when opening a database whose schema is incompatible with this
|
|
@@ -82,6 +85,19 @@ LEGACY_1X_TABLES = frozenset(
|
|
|
82
85
|
)
|
|
83
86
|
|
|
84
87
|
|
|
88
|
+
class TransactionTimeoutError(TimeoutError):
|
|
89
|
+
"""Raised when a transaction could not take the write lock in time.
|
|
90
|
+
|
|
91
|
+
beaver multiplexes every modality over a *single* connection, so tasks that
|
|
92
|
+
want to write queue on an in-process lock and SQLite's busy-timeout — which
|
|
93
|
+
only arbitrates between separate connections — never applies to them. The
|
|
94
|
+
bound is `connection_timeout` (or the per-call `timeout=` on
|
|
95
|
+
``db.transaction()``).
|
|
96
|
+
|
|
97
|
+
Seeing this means a writer is holding the transaction open longer than the
|
|
98
|
+
budget: a long-running block, a stalled task, or a genuine write storm."""
|
|
99
|
+
|
|
100
|
+
|
|
85
101
|
class Transaction:
|
|
86
102
|
"""
|
|
87
103
|
A Reentrant Async Context Manager for database transactions.
|
|
@@ -91,29 +107,128 @@ class Transaction:
|
|
|
91
107
|
at the outermost level.
|
|
92
108
|
"""
|
|
93
109
|
|
|
94
|
-
def __init__(self, db: "AsyncBeaverDB"):
|
|
110
|
+
def __init__(self, db: "AsyncBeaverDB", *, timeout: float | None = None):
|
|
95
111
|
self.db = db
|
|
96
112
|
self._is_root = False
|
|
113
|
+
self._timeout = db._timeout if timeout is None else timeout
|
|
97
114
|
|
|
98
115
|
async def __aenter__(self):
|
|
99
116
|
current_task = asyncio.current_task()
|
|
100
117
|
|
|
101
118
|
# 1. Check Reentrancy: Do we already own the transaction?
|
|
102
|
-
|
|
119
|
+
# `is not None` matters: outside a task `current_task()` is None,
|
|
120
|
+
# and a bare `==` would read that as owning the transaction.
|
|
121
|
+
if current_task is not None and self.db._tx_owner_task is current_task:
|
|
103
122
|
self._is_root = False
|
|
104
123
|
return self
|
|
105
124
|
|
|
106
|
-
# 2. Acquire
|
|
107
|
-
await self.
|
|
125
|
+
# 2. Acquire the write lock, bounded (see _acquire).
|
|
126
|
+
await self._acquire()
|
|
127
|
+
|
|
128
|
+
# 3. Start the DB transaction, THEN claim ownership.
|
|
129
|
+
#
|
|
130
|
+
# Order matters. `__aexit__` never runs when `__aenter__` raises, so
|
|
131
|
+
# everything between the acquire above and the return below has to
|
|
132
|
+
# clean up after itself — and ownership must not name a transaction
|
|
133
|
+
# that never began, or the reentrancy check above waves this task's
|
|
134
|
+
# next transaction through as a no-op.
|
|
135
|
+
#
|
|
136
|
+
# Using isolation_level=None globally means we MUST start manually.
|
|
137
|
+
try:
|
|
138
|
+
await self.db.connection.execute("BEGIN IMMEDIATE")
|
|
139
|
+
except BaseException:
|
|
140
|
+
try:
|
|
141
|
+
await self._rollback_quietly()
|
|
142
|
+
finally:
|
|
143
|
+
self.db._tx_lock.release()
|
|
144
|
+
raise
|
|
108
145
|
|
|
109
|
-
# 3. Mark ownership and start DB transaction
|
|
110
146
|
self.db._tx_owner_task = current_task
|
|
111
147
|
self._is_root = True
|
|
112
|
-
|
|
113
|
-
# Using isolation_level=None globally means we MUST start manually
|
|
114
|
-
await self.db.connection.execute("BEGIN IMMEDIATE")
|
|
115
148
|
return self
|
|
116
149
|
|
|
150
|
+
async def _acquire(self) -> None:
|
|
151
|
+
"""Take the write lock, waiting at most `self._timeout` seconds.
|
|
152
|
+
|
|
153
|
+
The wait has to be bounded because beaver multiplexes every modality
|
|
154
|
+
over a *single* connection: contending tasks queue here, on an
|
|
155
|
+
in-process `asyncio.Lock`, and SQLite's busy-timeout — which only
|
|
156
|
+
arbitrates between separate connections — never gets a say. An
|
|
157
|
+
unbounded wait here is indistinguishable from a hang, and in WAL mode
|
|
158
|
+
readers keep working throughout, so the process looks alive while every
|
|
159
|
+
writer is stuck behind it.
|
|
160
|
+
"""
|
|
161
|
+
if await self._steal_if_orphaned():
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
async with asyncio.timeout(self._timeout):
|
|
166
|
+
await self.db._tx_lock.acquire()
|
|
167
|
+
except TimeoutError:
|
|
168
|
+
# A holder that finished without releasing may have appeared while
|
|
169
|
+
# we waited; take it over rather than reporting contention.
|
|
170
|
+
if await self._steal_if_orphaned():
|
|
171
|
+
return
|
|
172
|
+
raise TransactionTimeoutError(
|
|
173
|
+
f"could not take the database write lock within "
|
|
174
|
+
f"{self._timeout:g}s — another task is holding a transaction "
|
|
175
|
+
f"open on {self.db._db_path!r}"
|
|
176
|
+
) from None
|
|
177
|
+
|
|
178
|
+
async def _steal_if_orphaned(self) -> bool:
|
|
179
|
+
"""Take over a lock whose owner has already finished.
|
|
180
|
+
|
|
181
|
+
A task that is `done()` cannot still be inside its `async with` body,
|
|
182
|
+
so nobody is coming back to commit or release: waiting out the timeout
|
|
183
|
+
would only delay an error. The rollback matters as much as the lock —
|
|
184
|
+
the orphan's `BEGIN IMMEDIATE` is what pins the file for every *other*
|
|
185
|
+
connection too, and SQLite refuses to begin a transaction inside one.
|
|
186
|
+
|
|
187
|
+
Returns True when this call now holds the lock. The lock object stays
|
|
188
|
+
`locked()` throughout; only ownership changes hands, so tasks already
|
|
189
|
+
queued on it keep waiting instead of racing us for it.
|
|
190
|
+
|
|
191
|
+
An owner of None means someone is mid-BEGIN and has not claimed the
|
|
192
|
+
lock yet — that is live, not orphaned. Nothing else can be `done()` and
|
|
193
|
+
still be running its transaction body, so there is no false positive
|
|
194
|
+
here that would roll back somebody's in-flight work.
|
|
195
|
+
"""
|
|
196
|
+
owner = self.db._tx_owner_task
|
|
197
|
+
if owner is None or not owner.done() or not self.db._tx_lock.locked():
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
# Loud, per beaver's rule about partial state: reclaiming is recovery
|
|
201
|
+
# from a bug somewhere, not a normal event, and a silent recovery is
|
|
202
|
+
# how the caller never learns it has a leak.
|
|
203
|
+
_log.warning(
|
|
204
|
+
"reclaiming an orphaned transaction on %r: its owning task (%s) "
|
|
205
|
+
"finished without committing or rolling back",
|
|
206
|
+
self.db._db_path,
|
|
207
|
+
owner.get_name(),
|
|
208
|
+
)
|
|
209
|
+
await self._rollback_quietly()
|
|
210
|
+
self.db._tx_owner_task = None
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
async def _rollback_quietly(self) -> None:
|
|
214
|
+
"""Undo a BEGIN that may have taken effect even though its await did not.
|
|
215
|
+
|
|
216
|
+
`await connection.execute("BEGIN IMMEDIATE")` is two things: the
|
|
217
|
+
statement running on aiosqlite's worker thread, and the future we wait
|
|
218
|
+
on. Cancelling the wait does not un-run the statement — so a cancelled
|
|
219
|
+
BEGIN can leave SQLite's write lock held with nothing left to release
|
|
220
|
+
it. That is the whole outage.
|
|
221
|
+
|
|
222
|
+
Shielded, because the rollback has to survive the very cancellation
|
|
223
|
+
that made it necessary. Errors are swallowed: this runs while another
|
|
224
|
+
exception is already propagating, and `rollback()` is a no-op when no
|
|
225
|
+
transaction turned out to be open.
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
await asyncio.shield(self.db.connection.rollback())
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
117
232
|
async def __aexit__(self, exc_type, exc, tb):
|
|
118
233
|
# Only the root transaction performs the Commit/Rollback and Release
|
|
119
234
|
if not self._is_root:
|
|
@@ -152,6 +267,10 @@ class AsyncBeaverDB:
|
|
|
152
267
|
pragma_mmap_size: int = 256 * 1024 * 1024,
|
|
153
268
|
):
|
|
154
269
|
self._db_path = db_path
|
|
270
|
+
# Bounds both ways a write can be made to wait: SQLite's busy-timeout
|
|
271
|
+
# (contention from another *connection*) and the transaction lock below
|
|
272
|
+
# (contention from another *task* on this one). Only the second one
|
|
273
|
+
# applies in a single-process app, which is why it must be bounded too.
|
|
155
274
|
self._timeout = connection_timeout
|
|
156
275
|
self._cache_timeout = cache_timeout
|
|
157
276
|
|
|
@@ -256,12 +375,18 @@ class AsyncBeaverDB:
|
|
|
256
375
|
|
|
257
376
|
return self._connection
|
|
258
377
|
|
|
259
|
-
def transaction(self) -> AsyncContextManager:
|
|
378
|
+
def transaction(self, *, timeout: float | None = None) -> AsyncContextManager:
|
|
260
379
|
"""
|
|
261
380
|
Returns an async context manager for an atomic transaction.
|
|
262
381
|
Use: async with db.transaction(): ...
|
|
382
|
+
|
|
383
|
+
`timeout` bounds the wait for the write lock, defaulting to this
|
|
384
|
+
database's `connection_timeout`. Pass a tighter one from a caller with
|
|
385
|
+
its own latency budget — a healthcheck, a request handler — so a
|
|
386
|
+
contended write fails fast with `TransactionTimeoutError` instead of
|
|
387
|
+
holding the caller for the full default.
|
|
263
388
|
"""
|
|
264
|
-
return Transaction(self)
|
|
389
|
+
return Transaction(self, timeout=timeout)
|
|
265
390
|
|
|
266
391
|
async def _tables_named(self, names) -> list[str]:
|
|
267
392
|
"""Return which of `names` exist as tables in the open database."""
|
|
@@ -484,6 +609,41 @@ class AsyncBeaverDB:
|
|
|
484
609
|
"CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON __beaver_logs__ (log_name, timestamp)"
|
|
485
610
|
)
|
|
486
611
|
|
|
612
|
+
# Field Index (issue #42) — uniform per-field indexing.
|
|
613
|
+
# Additive by design: new tables only, no ALTER, no version bump.
|
|
614
|
+
await c.execute(
|
|
615
|
+
"""
|
|
616
|
+
CREATE TABLE IF NOT EXISTS __beaver_field_index__ (
|
|
617
|
+
kind TEXT NOT NULL,
|
|
618
|
+
name TEXT NOT NULL,
|
|
619
|
+
item_key TEXT NOT NULL,
|
|
620
|
+
field TEXT NOT NULL,
|
|
621
|
+
value TEXT,
|
|
622
|
+
value_num REAL,
|
|
623
|
+
PRIMARY KEY (kind, name, item_key, field)
|
|
624
|
+
)
|
|
625
|
+
"""
|
|
626
|
+
)
|
|
627
|
+
await c.execute(
|
|
628
|
+
"CREATE INDEX IF NOT EXISTS idx_field_lookup "
|
|
629
|
+
"ON __beaver_field_index__ (kind, name, field, value)"
|
|
630
|
+
)
|
|
631
|
+
await c.execute(
|
|
632
|
+
"CREATE INDEX IF NOT EXISTS idx_field_lookup_num "
|
|
633
|
+
"ON __beaver_field_index__ (kind, name, field, value_num)"
|
|
634
|
+
)
|
|
635
|
+
await c.execute(
|
|
636
|
+
"""
|
|
637
|
+
CREATE TABLE IF NOT EXISTS __beaver_field_index_manifest__ (
|
|
638
|
+
kind TEXT NOT NULL,
|
|
639
|
+
name TEXT NOT NULL,
|
|
640
|
+
field TEXT NOT NULL,
|
|
641
|
+
complete INTEGER NOT NULL DEFAULT 0,
|
|
642
|
+
PRIMARY KEY (kind, name, field)
|
|
643
|
+
)
|
|
644
|
+
"""
|
|
645
|
+
)
|
|
646
|
+
|
|
487
647
|
# Priority Queues
|
|
488
648
|
await c.execute(
|
|
489
649
|
"""
|
|
@@ -685,9 +845,28 @@ class AsyncBeaverDB:
|
|
|
685
845
|
return self.singleton(AsyncBeaverBlob, name, model=model)
|
|
686
846
|
|
|
687
847
|
def log[T: BaseModel](
|
|
688
|
-
self,
|
|
848
|
+
self,
|
|
849
|
+
name: str,
|
|
850
|
+
model: type[T] | None = None,
|
|
851
|
+
# Quoted: inside a PEP 695 generic scope the class body is visible, so
|
|
852
|
+
# a bare `list[str]` resolves to AsyncBeaverDB.list, not the builtin.
|
|
853
|
+
indexed: "list[str] | None" = None,
|
|
689
854
|
) -> AsyncBeaverLog[T]:
|
|
690
|
-
|
|
855
|
+
"""Open a log.
|
|
856
|
+
|
|
857
|
+
`indexed` declares the fields whose values are maintained in the field
|
|
858
|
+
index. **Declare on first use:** `singleton()` keys its cache on
|
|
859
|
+
`(cls, name)` only, so after a bare `db.log("audit")` a later
|
|
860
|
+
`db.log("audit", indexed=[...])` returns the already-cached, undeclared
|
|
861
|
+
manager and the declaration is silently dropped.
|
|
862
|
+
"""
|
|
863
|
+
return self.singleton(AsyncBeaverLog, name, model=model, indexed=indexed)
|
|
864
|
+
|
|
865
|
+
async def indexes(self) -> dict:
|
|
866
|
+
"""Every declared field index in this database, by (kind, name)."""
|
|
867
|
+
from . import indexing
|
|
868
|
+
|
|
869
|
+
return await indexing.all_field_indexes(self.connection)
|
|
691
870
|
|
|
692
871
|
def lock(
|
|
693
872
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
import pydantic
|
|
4
4
|
from pydantic import BaseModel
|
|
5
5
|
|
|
6
|
-
from .core import
|
|
6
|
+
from .core import (
|
|
7
|
+
BeaverIncompatibleSchemaError,
|
|
8
|
+
BeaverLegacySchemaError,
|
|
9
|
+
TransactionTimeoutError,
|
|
10
|
+
)
|
|
7
11
|
|
|
8
12
|
|
|
9
13
|
class BeaverRemoteError(Exception):
|
|
@@ -26,6 +30,7 @@ _REGISTRY: dict[str, type[Exception]] = {
|
|
|
26
30
|
"TimeoutError": TimeoutError,
|
|
27
31
|
"BeaverIncompatibleSchemaError": BeaverIncompatibleSchemaError,
|
|
28
32
|
"BeaverLegacySchemaError": BeaverLegacySchemaError,
|
|
33
|
+
"TransactionTimeoutError": TransactionTimeoutError,
|
|
29
34
|
"ValidationError": pydantic.ValidationError,
|
|
30
35
|
}
|
|
31
36
|
|