secondbrain-py 0.2.1__py3-none-any.whl
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.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
brain/db.py
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"""Postgres connection + migration helpers."""
|
|
2
|
+
import importlib.resources
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager, suppress
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import psycopg
|
|
8
|
+
from pgvector.psycopg import register_vector
|
|
9
|
+
from psycopg import sql
|
|
10
|
+
|
|
11
|
+
from .embedding_targets import (
|
|
12
|
+
embedding_index_name,
|
|
13
|
+
validate_embedding_target,
|
|
14
|
+
)
|
|
15
|
+
from .errors import AgeBootstrapError, BrainError
|
|
16
|
+
from .ingest import Embedder
|
|
17
|
+
|
|
18
|
+
# Canonical Apache AGE graph name created by the ``brain init`` bootstrap and
|
|
19
|
+
# traversed by the graph backend (wave G0). Defined here — not in
|
|
20
|
+
# :mod:`brain.config` — so this low-level layer stays dependency-free; the
|
|
21
|
+
# configurable ``BRAIN_GRAPH_NAME`` override (spec §10) is resolved by higher
|
|
22
|
+
# layers and passed in explicitly as ``graph_name`` when it lands (G0-4+).
|
|
23
|
+
# ``tests/conftest.py``'s ``_reset_age_graph`` drops exactly this graph between
|
|
24
|
+
# tests, so the bootstrap and the reset must agree on the name.
|
|
25
|
+
DEFAULT_GRAPH_NAME = "brain_graph"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def connect_raw(database_url: str) -> psycopg.Connection:
|
|
29
|
+
"""Open a connection with pgvector adapter registered.
|
|
30
|
+
|
|
31
|
+
Same semantics as :func:`connect` but without the context-manager
|
|
32
|
+
wrapper. Callers own the connection lifecycle: they MUST call
|
|
33
|
+
``conn.close()`` themselves. Used by the long-running watcher, which
|
|
34
|
+
holds a connection across many sync calls in a worker thread; the
|
|
35
|
+
`with` block of :func:`connect` would auto-close it after the first
|
|
36
|
+
use.
|
|
37
|
+
"""
|
|
38
|
+
conn = psycopg.connect(database_url, connect_timeout=10)
|
|
39
|
+
# The vector extension may not exist yet during the initial `brain init`
|
|
40
|
+
# bootstrap; check pg_type before registering so we don't rely on
|
|
41
|
+
# exception-as-control-flow. The SELECT starts an implicit transaction
|
|
42
|
+
# under psycopg3's default autocommit=False — roll it back so the
|
|
43
|
+
# caller can still flip autocommit on if needed.
|
|
44
|
+
row = conn.execute(
|
|
45
|
+
"SELECT 1 FROM pg_type WHERE typname = 'vector'"
|
|
46
|
+
).fetchone()
|
|
47
|
+
conn.rollback()
|
|
48
|
+
if row is not None:
|
|
49
|
+
register_vector(conn)
|
|
50
|
+
return conn
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@contextmanager
|
|
54
|
+
def connect(database_url: str) -> Iterator[psycopg.Connection]:
|
|
55
|
+
"""Open a connection with pgvector adapter registered.
|
|
56
|
+
|
|
57
|
+
Tolerates the bootstrap case where the `vector` extension has not yet been
|
|
58
|
+
installed (e.g. the first `brain init` on a fresh database). In that case
|
|
59
|
+
the adapter registration is skipped; callers that need vector support
|
|
60
|
+
should open a new connection after `run_migrations`.
|
|
61
|
+
"""
|
|
62
|
+
conn = connect_raw(database_url)
|
|
63
|
+
try:
|
|
64
|
+
yield conn
|
|
65
|
+
finally:
|
|
66
|
+
conn.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _age_extension_installed(conn: psycopg.Connection) -> bool:
|
|
70
|
+
"""True iff the ``age`` extension object exists in this database.
|
|
71
|
+
|
|
72
|
+
``LOAD 'age'`` only loads the shared library; the openCypher catalog
|
|
73
|
+
functions (``cypher``, ``create_graph``, ...) come from the extension
|
|
74
|
+
object created by ``CREATE EXTENSION age``. On a fresh database — before
|
|
75
|
+
:func:`bootstrap_age` runs during ``brain init`` — that object is absent, so
|
|
76
|
+
the session helpers must no-op rather than fail. Mirrors the
|
|
77
|
+
``vector``-extension tolerance in :func:`connect_raw`.
|
|
78
|
+
"""
|
|
79
|
+
row = conn.execute(
|
|
80
|
+
"SELECT 1 FROM pg_extension WHERE extname = 'age'"
|
|
81
|
+
).fetchone()
|
|
82
|
+
return row is not None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_age(conn: psycopg.Connection) -> bool:
|
|
86
|
+
"""Make Apache AGE callable on ``conn`` for the rest of the session.
|
|
87
|
+
|
|
88
|
+
Issues ``LOAD 'age'`` so the openCypher catalog functions become available.
|
|
89
|
+
AGE requires this once per backend session even when the extension object
|
|
90
|
+
already exists — verified against the live AGE image, where a fresh
|
|
91
|
+
connection that skips ``LOAD`` raises
|
|
92
|
+
``unhandled cypher(cstring) function call``.
|
|
93
|
+
|
|
94
|
+
Deliberately does **not** mutate ``search_path``. The graph backend
|
|
95
|
+
fully-qualifies every call (``ag_catalog.cypher(...) AS (col
|
|
96
|
+
ag_catalog.agtype)``), which the live AGE image accepts without an
|
|
97
|
+
``ag_catalog``-first search_path. Avoiding the global path keeps the
|
|
98
|
+
contract identical to ``tests/conftest.py``'s ``_reset_age_graph`` (which
|
|
99
|
+
explicitly refuses to leak ``ag_catalog`` onto the session) and guarantees
|
|
100
|
+
zero impact on the unqualified ``public`` queries every other command runs.
|
|
101
|
+
|
|
102
|
+
Tolerates the bootstrap window where the extension is not yet installed
|
|
103
|
+
(returns ``False`` without loading) so :func:`connect_age` is safe on a
|
|
104
|
+
fresh database. ``LOAD`` is a process-level effect that survives a
|
|
105
|
+
transaction rollback, so the implicit transaction opened under psycopg's
|
|
106
|
+
default ``autocommit=False`` is rolled back here — mirroring
|
|
107
|
+
:func:`connect_raw` — leaving the caller free to flip ``autocommit``
|
|
108
|
+
afterwards.
|
|
109
|
+
|
|
110
|
+
Returns ``True`` when AGE was loaded, ``False`` when the extension is
|
|
111
|
+
absent (nothing loaded). Raises :class:`AgeBootstrapError` (never a raw
|
|
112
|
+
``psycopg.Error``) if the catalog probe or ``LOAD`` fails.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
installed = _age_extension_installed(conn)
|
|
116
|
+
if installed:
|
|
117
|
+
conn.execute("LOAD 'age'")
|
|
118
|
+
except psycopg.Error as exc:
|
|
119
|
+
# Clear the aborted transaction first so the connection stays usable,
|
|
120
|
+
# then surface a typed bootstrap failure (no raw psycopg.Error escapes).
|
|
121
|
+
if not conn.autocommit:
|
|
122
|
+
conn.rollback()
|
|
123
|
+
raise AgeBootstrapError(f"failed to LOAD Apache AGE: {exc}") from exc
|
|
124
|
+
# Clear the implicit transaction opened by the SELECT (and LOAD) so the
|
|
125
|
+
# caller can still flip autocommit; harmless no-op under autocommit=True.
|
|
126
|
+
if not conn.autocommit:
|
|
127
|
+
conn.rollback()
|
|
128
|
+
return installed
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@contextmanager
|
|
132
|
+
def connect_age(database_url: str) -> Iterator[psycopg.Connection]:
|
|
133
|
+
"""Open a connection with pgvector registered **and** Apache AGE loaded.
|
|
134
|
+
|
|
135
|
+
Thin wrapper over :func:`connect` that additionally runs :func:`load_age`,
|
|
136
|
+
so graph callers don't repeat the per-session ``LOAD 'age'`` bootstrap. On a
|
|
137
|
+
fresh database where the ``age`` extension isn't installed yet,
|
|
138
|
+
:func:`load_age` no-ops; the connection is still usable for the relational
|
|
139
|
+
source-of-truth tables.
|
|
140
|
+
"""
|
|
141
|
+
with connect(database_url) as conn:
|
|
142
|
+
load_age(conn)
|
|
143
|
+
yield conn
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def migrations_dir() -> Path:
|
|
147
|
+
"""Path to the packaged migrations directory (``brain/migrations``).
|
|
148
|
+
|
|
149
|
+
Resolved via :mod:`importlib.resources` so it works in BOTH editable
|
|
150
|
+
(``pip install -e``) and wheel / pip installs: the SQL files ship inside the
|
|
151
|
+
``brain`` package itself (see pyproject ``[tool.setuptools.package-data]``),
|
|
152
|
+
not at the repo root. Before 0.2.1 they lived at ``<repo>/migrations`` — a
|
|
153
|
+
sibling of ``src/`` — which setuptools never bundled, so ``brain init``
|
|
154
|
+
globbed an empty directory and applied zero migrations on wheel installs.
|
|
155
|
+
|
|
156
|
+
``brain`` is a regular, never-zip-imported package, so the ``Traversable``
|
|
157
|
+
returned by ``files()`` is already a concrete filesystem path; the ``Path``
|
|
158
|
+
cast is safe and keeps ``.glob`` working at the call site.
|
|
159
|
+
"""
|
|
160
|
+
return Path(str(importlib.resources.files("brain").joinpath("migrations")))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_SCHEMA_MIGRATIONS_DDL = """
|
|
164
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
165
|
+
name TEXT PRIMARY KEY,
|
|
166
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
167
|
+
)
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _table_exists(conn: psycopg.Connection, name: str) -> bool:
|
|
172
|
+
row = conn.execute(
|
|
173
|
+
"SELECT 1 FROM information_schema.tables "
|
|
174
|
+
"WHERE table_schema = 'public' AND table_name = %s",
|
|
175
|
+
(name,),
|
|
176
|
+
).fetchone()
|
|
177
|
+
return row is not None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _column_exists(conn: psycopg.Connection, table: str, column: str) -> bool:
|
|
181
|
+
row = conn.execute(
|
|
182
|
+
"SELECT 1 FROM information_schema.columns "
|
|
183
|
+
"WHERE table_schema = 'public' AND table_name = %s AND column_name = %s",
|
|
184
|
+
(table, column),
|
|
185
|
+
).fetchone()
|
|
186
|
+
return row is not None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _index_exists(conn: psycopg.Connection, name: str) -> bool:
|
|
190
|
+
row = conn.execute(
|
|
191
|
+
"SELECT 1 FROM pg_indexes WHERE indexname = %s",
|
|
192
|
+
(name,),
|
|
193
|
+
).fetchone()
|
|
194
|
+
return row is not None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _seed_applied_migrations(conn: psycopg.Connection) -> None:
|
|
198
|
+
"""Detect and record migrations already applied to a pre-existing schema.
|
|
199
|
+
|
|
200
|
+
Run on first encounter with a DB that predates ``schema_migrations``. We
|
|
201
|
+
can't run prior migrations against a populated DB without crashing
|
|
202
|
+
(``CREATE TABLE`` collides; ``ALTER TABLE DROP COLUMN`` on 002 would lose
|
|
203
|
+
data), so we infer their applied state from schema artifacts and seed the
|
|
204
|
+
tracking table. Subsequent runs then skip them.
|
|
205
|
+
|
|
206
|
+
Detection is conservative: 002 is treated as applied whenever 001 is,
|
|
207
|
+
since 002 is the documented qwen3 backend swap that's been the only
|
|
208
|
+
supported path for any DB old enough to lack ``schema_migrations``.
|
|
209
|
+
"""
|
|
210
|
+
if _table_exists(conn, "sources"):
|
|
211
|
+
conn.execute(
|
|
212
|
+
"INSERT INTO schema_migrations (name) VALUES (%s) "
|
|
213
|
+
"ON CONFLICT (name) DO NOTHING",
|
|
214
|
+
("001_init.sql",),
|
|
215
|
+
)
|
|
216
|
+
conn.execute(
|
|
217
|
+
"INSERT INTO schema_migrations (name) VALUES (%s) "
|
|
218
|
+
"ON CONFLICT (name) DO NOTHING",
|
|
219
|
+
("002_qwen3_embedding.sql",),
|
|
220
|
+
)
|
|
221
|
+
if _column_exists(conn, "documents", "kind"):
|
|
222
|
+
conn.execute(
|
|
223
|
+
"INSERT INTO schema_migrations (name) VALUES (%s) "
|
|
224
|
+
"ON CONFLICT (name) DO NOTHING",
|
|
225
|
+
("003_vault_model.sql",),
|
|
226
|
+
)
|
|
227
|
+
if _index_exists(conn, "documents_content_hash_ingested_idx"):
|
|
228
|
+
conn.execute(
|
|
229
|
+
"INSERT INTO schema_migrations (name) VALUES (%s) "
|
|
230
|
+
"ON CONFLICT (name) DO NOTHING",
|
|
231
|
+
("004_relax_content_hash_uniqueness.sql",),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def run_migrations(conn: psycopg.Connection) -> list[str]:
|
|
236
|
+
"""Apply pending migrations in name order. Returns the list newly applied.
|
|
237
|
+
|
|
238
|
+
Tracks applied migrations in the ``schema_migrations`` table so each .sql
|
|
239
|
+
file runs at most once. On first run against a pre-existing schema (no
|
|
240
|
+
``schema_migrations`` table yet), seeds the table from schema state via
|
|
241
|
+
:func:`_seed_applied_migrations` so the prior CREATE TABLE / ALTER COLUMN
|
|
242
|
+
statements aren't re-attempted.
|
|
243
|
+
"""
|
|
244
|
+
mdir = migrations_dir()
|
|
245
|
+
migration_files = sorted(mdir.glob("*.sql"))
|
|
246
|
+
if not migration_files:
|
|
247
|
+
# Loud-fail guard: an empty migrations dir means the package was installed
|
|
248
|
+
# without its SQL (the pre-0.2.1 wheel bug). Raise instead of silently
|
|
249
|
+
# applying zero migrations and leaving an empty schema.
|
|
250
|
+
raise BrainError(
|
|
251
|
+
f"No migration files found under {mdir} — the package is installed "
|
|
252
|
+
"incorrectly (migrations were not shipped in the wheel). Reinstall "
|
|
253
|
+
"from a build that includes src/brain/migrations/*.sql."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
conn.execute(_SCHEMA_MIGRATIONS_DDL)
|
|
257
|
+
seeded_row = conn.execute("SELECT count(*) FROM schema_migrations").fetchone()
|
|
258
|
+
assert seeded_row is not None # count(*) always yields one row
|
|
259
|
+
if int(seeded_row[0]) == 0:
|
|
260
|
+
_seed_applied_migrations(conn)
|
|
261
|
+
|
|
262
|
+
rows = conn.execute("SELECT name FROM schema_migrations").fetchall()
|
|
263
|
+
applied_names = {str(r[0]) for r in rows}
|
|
264
|
+
|
|
265
|
+
applied: list[str] = []
|
|
266
|
+
for sql_file in migration_files:
|
|
267
|
+
if sql_file.name in applied_names:
|
|
268
|
+
continue
|
|
269
|
+
conn.execute(sql_file.read_text())
|
|
270
|
+
conn.execute(
|
|
271
|
+
"INSERT INTO schema_migrations (name) VALUES (%s)",
|
|
272
|
+
(sql_file.name,),
|
|
273
|
+
)
|
|
274
|
+
applied.append(sql_file.name)
|
|
275
|
+
return applied
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def age_extension_available(conn: psycopg.Connection) -> bool:
|
|
279
|
+
"""True iff the ``age`` extension is *installable* in this database.
|
|
280
|
+
|
|
281
|
+
Probes ``pg_available_extensions`` — the catalog of extensions whose control
|
|
282
|
+
files are present on the server (i.e. extensions that *can* be
|
|
283
|
+
``CREATE EXTENSION``-ed) — as opposed to ``pg_extension``, which lists the
|
|
284
|
+
extensions already *installed* (the latter is what
|
|
285
|
+
:func:`_age_extension_installed` checks). On a stock pgvector image (a prod
|
|
286
|
+
DB before the Apache AGE cut-over) ``age`` is absent here, so ``brain init``
|
|
287
|
+
can SKIP the AGE bootstrap instead of crashing on ``CREATE EXTENSION age``
|
|
288
|
+
after the relational migrations have already committed.
|
|
289
|
+
|
|
290
|
+
Rolls back the implicit read transaction opened by the SELECT under
|
|
291
|
+
psycopg's default ``autocommit=False`` so the caller's connection stays
|
|
292
|
+
clean (a harmless no-op when the caller is already autocommit, as ``init``
|
|
293
|
+
is) — on BOTH the success and failure paths: if the probe statement itself
|
|
294
|
+
raises on a non-autocommit connection it would otherwise leave the
|
|
295
|
+
transaction aborted, poisoning the caller's connection. Mirrors the
|
|
296
|
+
``vector``-extension tolerance in :func:`connect_raw`.
|
|
297
|
+
"""
|
|
298
|
+
try:
|
|
299
|
+
row = conn.execute(
|
|
300
|
+
"SELECT 1 FROM pg_available_extensions WHERE name = 'age'"
|
|
301
|
+
).fetchone()
|
|
302
|
+
except psycopg.Error:
|
|
303
|
+
# Clear the aborted transaction the failed SELECT may have opened on a
|
|
304
|
+
# non-autocommit connection, then re-raise (the probe makes no claim it
|
|
305
|
+
# can swallow DB failures — the caller decides how to surface them).
|
|
306
|
+
if not conn.autocommit:
|
|
307
|
+
conn.rollback()
|
|
308
|
+
raise
|
|
309
|
+
if not conn.autocommit:
|
|
310
|
+
conn.rollback()
|
|
311
|
+
return row is not None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def bootstrap_age(
|
|
315
|
+
conn: psycopg.Connection,
|
|
316
|
+
graph_name: str = DEFAULT_GRAPH_NAME,
|
|
317
|
+
) -> bool:
|
|
318
|
+
"""Idempotently provision the Apache AGE extension + the canonical graph.
|
|
319
|
+
|
|
320
|
+
Invoked by ``brain init`` after :func:`run_migrations`. All steps are
|
|
321
|
+
idempotent / guarded:
|
|
322
|
+
|
|
323
|
+
1. ``CREATE EXTENSION IF NOT EXISTS age CASCADE`` — installs the catalog
|
|
324
|
+
functions if absent; no-op otherwise.
|
|
325
|
+
2. ``LOAD 'age'`` — required before ``create_graph`` is callable.
|
|
326
|
+
3. Check ``ag_catalog.ag_graph`` for ``graph_name`` and call
|
|
327
|
+
``ag_catalog.create_graph`` **only when absent**. ``create_graph`` raises
|
|
328
|
+
``InvalidSchemaName`` ("graph already exists") on a second call, so the
|
|
329
|
+
existence guard is mandatory (verified against the live AGE image); it is
|
|
330
|
+
what makes re-running ``brain init`` a safe no-op.
|
|
331
|
+
|
|
332
|
+
AGE catalog DDL (``CREATE EXTENSION`` / ``create_graph``) does not behave
|
|
333
|
+
well inside an open transaction under psycopg v3, so this **requires
|
|
334
|
+
``conn.autocommit`` to be True** and raises :class:`BrainError` otherwise.
|
|
335
|
+
``brain init`` already sets autocommit before running migrations.
|
|
336
|
+
|
|
337
|
+
Every AGE catalog reference is fully-qualified (``ag_catalog.*``) and no
|
|
338
|
+
``search_path`` is mutated, so the surrounding ``init`` work (migrations,
|
|
339
|
+
:func:`ensure_embedding_column`, search backfill) keeps targeting
|
|
340
|
+
``public``.
|
|
341
|
+
|
|
342
|
+
Returns ``True`` when the graph was created by this call, ``False`` when it
|
|
343
|
+
already existed (re-run no-op). Raises :class:`AgeBootstrapError` (never a
|
|
344
|
+
raw ``psycopg.Error``) on any AGE catalog DDL failure; the autocommit
|
|
345
|
+
precondition is a separate, plain :class:`BrainError` (caller bug).
|
|
346
|
+
|
|
347
|
+
**Vertex/edge labels and property indexes are intentionally NOT created
|
|
348
|
+
here.** Per the phase split (plan §G0-4 / spec §12), the G0-4
|
|
349
|
+
``GraphBackend`` owns entity/edge upserts and will create its labels (via
|
|
350
|
+
``MERGE`` / ``create_vlabel``/``create_elabel``) and the matching per-label
|
|
351
|
+
property indexes (spec §5b: ``tenant_id``, ``entity_uuid``,
|
|
352
|
+
``canonical_key``, ``CO_OCCURS.weight``) at that point — when G0-3's
|
|
353
|
+
tenantized relational schema and the ``tenant_id`` property contract are in
|
|
354
|
+
place. AGE property indexes target a specific label's backing table, which
|
|
355
|
+
cannot exist before its label is created, so pre-creating empty labels now
|
|
356
|
+
would duplicate G0-4's ownership and risk drift. Deferred deliberately.
|
|
357
|
+
"""
|
|
358
|
+
if not conn.autocommit:
|
|
359
|
+
raise BrainError(
|
|
360
|
+
"bootstrap_age requires an autocommit connection — AGE catalog DDL "
|
|
361
|
+
"does not run reliably inside an open transaction under psycopg v3"
|
|
362
|
+
)
|
|
363
|
+
try:
|
|
364
|
+
conn.execute("CREATE EXTENSION IF NOT EXISTS age CASCADE")
|
|
365
|
+
conn.execute("LOAD 'age'")
|
|
366
|
+
existing = conn.execute(
|
|
367
|
+
"SELECT 1 FROM ag_catalog.ag_graph WHERE name = %s",
|
|
368
|
+
(graph_name,),
|
|
369
|
+
).fetchone()
|
|
370
|
+
if existing is not None:
|
|
371
|
+
return False
|
|
372
|
+
conn.execute("SELECT ag_catalog.create_graph(%s)", (graph_name,))
|
|
373
|
+
except psycopg.Error as exc:
|
|
374
|
+
raise AgeBootstrapError(
|
|
375
|
+
f"failed to bootstrap Apache AGE graph {graph_name!r}: {exc}"
|
|
376
|
+
) from exc
|
|
377
|
+
return True
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _current_embedding_dim(
|
|
381
|
+
conn: psycopg.Connection, table: str, column: str
|
|
382
|
+
) -> int:
|
|
383
|
+
"""Return the dim declared in ``<table>.<column>``'s ``vector(N)`` type.
|
|
384
|
+
|
|
385
|
+
The table/column are bound as *values* through the ``::regclass`` cast and
|
|
386
|
+
an ``attname`` equality (not interpolated into SQL text), so this query is
|
|
387
|
+
safe for any name. Callers still validate against the allowlist before
|
|
388
|
+
issuing DDL.
|
|
389
|
+
|
|
390
|
+
Raises :class:`BrainError` if the column doesn't exist or the type isn't
|
|
391
|
+
a ``vector(N)``. Both are bugs (migrations should always shape the
|
|
392
|
+
column), not user-facing conditions.
|
|
393
|
+
"""
|
|
394
|
+
row = conn.execute(
|
|
395
|
+
"SELECT format_type(atttypid, atttypmod) FROM pg_attribute "
|
|
396
|
+
"WHERE attrelid = %s::regclass AND attname = %s",
|
|
397
|
+
(table, column),
|
|
398
|
+
).fetchone()
|
|
399
|
+
if row is None:
|
|
400
|
+
raise BrainError(
|
|
401
|
+
f"{table}.{column} column not found — run brain init first"
|
|
402
|
+
)
|
|
403
|
+
formatted = str(row[0])
|
|
404
|
+
# format_type returns e.g. ``vector(1024)``; strip + parse the int.
|
|
405
|
+
if not (formatted.startswith("vector(") and formatted.endswith(")")):
|
|
406
|
+
raise BrainError(
|
|
407
|
+
f"unexpected {table}.{column} column type: {formatted!r}"
|
|
408
|
+
)
|
|
409
|
+
return int(formatted[len("vector(") : -1])
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def ensure_embedding_column(
|
|
413
|
+
conn: psycopg.Connection,
|
|
414
|
+
embedder: Embedder,
|
|
415
|
+
table: str = "chunks",
|
|
416
|
+
column: str = "embedding",
|
|
417
|
+
) -> None:
|
|
418
|
+
"""Reconcile a pgvector embedding column's dim with the active embedder.
|
|
419
|
+
|
|
420
|
+
Generalized over ``(table, column)`` (default ``chunks.embedding``) so the
|
|
421
|
+
GraphRAG tables can reuse it; ``(table, column)`` is checked against the
|
|
422
|
+
hard-coded allowlist in :mod:`brain.embedding_targets` and every identifier
|
|
423
|
+
is quoted via :class:`psycopg.sql.Identifier` — never string-formatted into
|
|
424
|
+
SQL.
|
|
425
|
+
|
|
426
|
+
Idempotent. The contract (applies to whichever ``(table, column)`` is
|
|
427
|
+
passed; the re-added column is **nullable**, matching the migration shape —
|
|
428
|
+
NOT NULL is a separate finalize concern handled by
|
|
429
|
+
:func:`brain.queries.finalize_embedding_index`):
|
|
430
|
+
|
|
431
|
+
- Column dim already matches ``embedder.dim`` → no-op.
|
|
432
|
+
- Mismatch with zero non-NULL embeddings → drop + re-add the column at
|
|
433
|
+
``embedder.dim`` (and drop any leftover HNSW index, which would point at
|
|
434
|
+
a column that's about to disappear). Safe — there are no embeddings to
|
|
435
|
+
lose. Existing rows are preserved; only the (NULL) embedding column is
|
|
436
|
+
rebuilt at the new dim.
|
|
437
|
+
- Mismatch with one or more non-NULL embeddings → raise
|
|
438
|
+
:class:`BrainError` instructing the user to do a destructive reset.
|
|
439
|
+
Switching backends with populated embeddings is intentionally not
|
|
440
|
+
silent; those embeddings would all be invalidated and re-embedding is
|
|
441
|
+
the only correct recovery.
|
|
442
|
+
|
|
443
|
+
Called by ``brain init`` after :func:`run_migrations` so ``chunks.embedding``
|
|
444
|
+
always matches the configured backend before any embeddings are written;
|
|
445
|
+
the GraphRAG reconcile path uses it for ``graph_entities.embedding``.
|
|
446
|
+
"""
|
|
447
|
+
validate_embedding_target(table, column)
|
|
448
|
+
current_dim = _current_embedding_dim(conn, table, column)
|
|
449
|
+
if current_dim == embedder.dim:
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
# Count rows that ACTUALLY hold a vector. Rows whose embedding is NULL
|
|
453
|
+
# (e.g. immediately after migration 002 drops + re-adds the column, or
|
|
454
|
+
# after `brain reembed` ingest of new docs that haven't been embedded)
|
|
455
|
+
# contribute no data we'd lose by resizing.
|
|
456
|
+
populated_sql = sql.SQL(
|
|
457
|
+
"SELECT count(*) FROM {table} WHERE {column} IS NOT NULL"
|
|
458
|
+
).format(table=sql.Identifier(table), column=sql.Identifier(column))
|
|
459
|
+
row = conn.execute(populated_sql).fetchone()
|
|
460
|
+
assert row is not None # count(*) always yields one row
|
|
461
|
+
populated = int(row[0])
|
|
462
|
+
if populated > 0:
|
|
463
|
+
raise BrainError(
|
|
464
|
+
f"Embedding column {table}.{column} is vector({current_dim}) but "
|
|
465
|
+
f"BRAIN_EMBEDDER expects vector({embedder.dim}). Switching backends "
|
|
466
|
+
f"with existing embeddings requires a destructive reset. Run: "
|
|
467
|
+
f"docker compose down && rm -rf data/postgres && "
|
|
468
|
+
f"docker compose up -d && brain init && brain reembed"
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
index_name = embedding_index_name(table, column)
|
|
472
|
+
with conn.transaction():
|
|
473
|
+
conn.execute(
|
|
474
|
+
sql.SQL("DROP INDEX IF EXISTS {index}").format(
|
|
475
|
+
index=sql.Identifier(index_name)
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
conn.execute(
|
|
479
|
+
sql.SQL("ALTER TABLE {table} DROP COLUMN {column}").format(
|
|
480
|
+
table=sql.Identifier(table), column=sql.Identifier(column)
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
conn.execute(
|
|
484
|
+
sql.SQL(
|
|
485
|
+
"ALTER TABLE {table} ADD COLUMN {column} vector({dim})"
|
|
486
|
+
).format(
|
|
487
|
+
table=sql.Identifier(table),
|
|
488
|
+
column=sql.Identifier(column),
|
|
489
|
+
dim=sql.Literal(embedder.dim),
|
|
490
|
+
)
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class PersistentConnection:
|
|
495
|
+
"""A single long-lived psycopg connection for the MCP server.
|
|
496
|
+
|
|
497
|
+
Opened lazily on first :meth:`get` call and reused across all subsequent
|
|
498
|
+
MCP tool calls, eliminating the ~10–30 ms per-call TCP handshake overhead.
|
|
499
|
+
Kept in ``autocommit=True`` mode so write helpers can manage transactions
|
|
500
|
+
explicitly via ``conn.transaction()`` without a wrapping session-level
|
|
501
|
+
transaction blocking them.
|
|
502
|
+
|
|
503
|
+
On :class:`psycopg.OperationalError` during a tool call, callers invoke
|
|
504
|
+
:meth:`reconnect` once before retrying; if the reconnect itself fails the
|
|
505
|
+
``OperationalError`` propagates as ``INTERNAL_ERROR``.
|
|
506
|
+
|
|
507
|
+
Intended exclusively for the MCP server — CLI invocations use the
|
|
508
|
+
per-call :func:`connect` context-manager so their connection lifetime
|
|
509
|
+
remains bounded.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
def __init__(self, database_url: str) -> None:
|
|
513
|
+
self._url = database_url
|
|
514
|
+
self._conn: psycopg.Connection | None = None
|
|
515
|
+
|
|
516
|
+
def get(self) -> psycopg.Connection:
|
|
517
|
+
"""Return the live connection, opening it lazily if absent or closed."""
|
|
518
|
+
if self._conn is None or self._conn.closed:
|
|
519
|
+
self._conn = connect_raw(self._url)
|
|
520
|
+
self._conn.autocommit = True
|
|
521
|
+
return self._conn
|
|
522
|
+
|
|
523
|
+
def reconnect(self) -> None:
|
|
524
|
+
"""Close the current connection (if any) and open a fresh one.
|
|
525
|
+
|
|
526
|
+
Raises :class:`psycopg.OperationalError` when the new connection
|
|
527
|
+
cannot be established — callers surface this as ``INTERNAL_ERROR``.
|
|
528
|
+
"""
|
|
529
|
+
if self._conn is not None and not self._conn.closed:
|
|
530
|
+
with suppress(psycopg.Error): # best-effort close; open fresh one below
|
|
531
|
+
self._conn.close()
|
|
532
|
+
self._conn = connect_raw(self._url)
|
|
533
|
+
self._conn.autocommit = True
|
|
534
|
+
|
|
535
|
+
def close(self) -> None:
|
|
536
|
+
"""Shut down the connection gracefully (called at server teardown)."""
|
|
537
|
+
if self._conn is not None and not self._conn.closed:
|
|
538
|
+
with suppress(psycopg.Error):
|
|
539
|
+
self._conn.close()
|
|
540
|
+
self._conn = None
|