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.
- beaver_db-2.3.0/AGENTS.md +143 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/PKG-INFO +1 -1
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/__init__.py +1 -1
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/cli/discovery.py +1 -1
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/core.py +56 -2
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/docs.py +6 -5
- beaver_db-2.3.0/beaver/indexing.py +260 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/logs.py +156 -3
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/manager.py +8 -1
- beaver_db-2.3.0/issues/42-uniform-field-indexing-across-collections.md +410 -0
- beaver_db-2.3.0/issues/43-singleton-silently-ignores-differing-kwargs-on-a-cache-hit.md +100 -0
- beaver_db-2.3.0/issues/44-filters-have-no-cli-or-wire-encoding-so-explain-cannot-be-exposed.md +100 -0
- beaver_db-2.3.0/plans/2026-08-01-field-indexing-slices-1-2.md +1654 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/pyproject.toml +1 -1
- beaver_db-2.3.0/tests/unit/test_field_indexing.py +463 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/uv.lock +1 -1
- {beaver_db-2.2.0 → beaver_db-2.3.0}/.dockerignore +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/.github/workflows/ci.yml +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/.github/workflows/release.yaml +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/.github/workflows/tests.yaml +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/.gitignore +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/.python-version +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/.vscode/settings.json +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/CHANGELOG.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/LICENSE +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/README.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/STATUS.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/_fracdex.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/api.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/blobs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/bridge.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/cache.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/channels.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/cli/__init__.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/cli/main.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/client.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/dicts.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/errors.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/events.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/graphs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/interfaces.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/lists.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/locks.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/migrate.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/queries.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/queues.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/security.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/server.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/sketches.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/beaver/vectors.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/design.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/dockerfile +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/.gitignore +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/_quarto.yml +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/dev-architecture.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/dev-concurrency.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/dev-contributing.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/dev-search.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-blobs.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-channels.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-collections.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-deployment.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-dicts.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-lists.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-locks.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-logs.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-queues.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide-sketches.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/guide.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/index.html +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/intro.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/logo.png +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/migration-1x-to-2x.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/migration-rc3-to-rc4-lists.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/quickstart.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/superpowers/plans/2026-05-24-list-fracdex-ordering.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/docs/superpowers/specs/2026-05-24-list-fracdex-ordering-design.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/async_pubsub.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/blobs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/cache.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/fts.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/fuzzy.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/general_test.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/graph.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/kvstore.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/list.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/locks.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/logs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/pqueue.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/producer_consumer.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/publisher.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/pubsub.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/rerank.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/stress_vectors.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/subscriber.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/textual_chat.css +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/textual_chat.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/type_hints.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/examples/vector.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/15-enhance-cli-with-admin-commands-shell-piping-and-interactivity.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/18-enhanced-dump-and-load-for-etl.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/19-add-comprehensive-unit-integration-and-concurrency-test-suite.md +0 -0
- {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
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/27-batched-operations-api-batched.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/28-add-hnsw-vector-strategy-beaver-dbhnsw.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/36-implement-sid-consumers-dynamic-cli-universal-client-server.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/37-v2-release-phase-0-audit-and-stabilize.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/38-v2-release-phase-1-storage-engine.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/39-v2-release-phase-2-wrappers.md +0 -0
- {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
- {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
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/1-refactor-vector-store-to-use-faiss.md +0 -0
- {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
- {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
- {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
- {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
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/16-add-clear-method-for-all-data-structures.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/17-replace-atomic-operations-with-custom-beaver-lock.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/2-comprehensive-async-wrappers.md +0 -0
- {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
- {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
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/23-global-event-listeners-api.md +0 -0
- {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
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/30-add-probabilistic-sketches-approximate-sets-counters.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/32-secure-dictionaries-encryption-and-hashing.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/34-composable-graph-algorithms-query-builder-graphrag.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/35-refactor-core-to-shared-interface-definition-sid-architecture.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/5-add-dblock-for-inter-process-synchronization.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/6-drop-in-replacement-for-beaver-rest-server-client.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/7-replace-faiss-with-simpler-linear-numpy-vectorial-search.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/8-first-class-synchronization-primitive.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/issues/closed/9-type-safe-wrappers-based-on-pydantic-compatible-models.md +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/logo.png +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/makefile +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/pytest.ini +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/benchmark_lsh.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/concurrency/conftest.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/concurrency/test_concurrency.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/conftest.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/integration/__init__.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/integration/test_server_subprocess.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/integration/test_sync.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/perftest.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/test_docs_search_filters.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_api_expose.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_batched.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_blobs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_bridge.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_channels.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_cli.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_client_local_only.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_client_roundtrip.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_client_roundtrip_lists.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_client_roundtrip_logs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_client_roundtrip_queues.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_connect.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_db_version.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_dicts.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_dicts_decorated.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_docs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_errors.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_events.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_fracdex.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_graphs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_lists.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_lists_decorated.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_lists_fracdex.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_locks.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_logs.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_logs_decorated.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_lsh.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_manager_events.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_migrate.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_queues.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_queues_decorated.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_security.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_server_auth.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_server_errors.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_server_routes.py +0 -0
- {beaver_db-2.2.0 → beaver_db-2.3.0}/tests/unit/test_sketchs.py +0 -0
- {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.
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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}
|