adopt-store 0.3.0__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.
- adopt_store/__init__.py +60 -0
- adopt_store/annex/__init__.py +25 -0
- adopt_store/annex/sqlite_annex.py +146 -0
- adopt_store/api.py +444 -0
- adopt_store/doctor.py +201 -0
- adopt_store/facades/__init__.py +43 -0
- adopt_store/facades/boundary.py +125 -0
- adopt_store/facades/identity.py +201 -0
- adopt_store/facades/knowledge.py +223 -0
- adopt_store/facades/records.py +214 -0
- adopt_store/facades/sensors.py +197 -0
- adopt_store/py.typed +0 -0
- adopt_store/revisions.py +556 -0
- adopt_store/sqlite/__init__.py +7 -0
- adopt_store/sqlite/connection.py +143 -0
- adopt_store/sqlite/records.py +908 -0
- adopt_store/sqlite/store.py +235 -0
- adopt_store/vector/__init__.py +8 -0
- adopt_store/vector/api.py +49 -0
- adopt_store-0.3.0.dist-info/METADATA +20 -0
- adopt_store-0.3.0.dist-info/RECORD +24 -0
- adopt_store-0.3.0.dist-info/WHEEL +4 -0
- adopt_store-0.3.0.dist-info/licenses/LICENSE +201 -0
- adopt_store-0.3.0.dist-info/licenses/NOTICE +39 -0
adopt_store/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""SQLite realization, facades, transactions, the VectorIndex seam.
|
|
2
|
+
|
|
3
|
+
Implementation spec §4.7. Invariants enforced here and by CI rather than by
|
|
4
|
+
review:
|
|
5
|
+
|
|
6
|
+
* **`sqlite3` is imported in `adopt_store.sqlite` and nowhere else**
|
|
7
|
+
(`no-raw-sqlite`), so no caller above the store can hold a connection.
|
|
8
|
+
* **No facade returns a connection, cursor or raw SQL** (contracts §10.3).
|
|
9
|
+
* **No destructive statement exists anywhere in the package** -- no `DROP`, no
|
|
10
|
+
`ALTER ... DROP`, no unpredicated `DELETE` -- checked by
|
|
11
|
+
`scripts/no_destructive_sql.py`.
|
|
12
|
+
* **Ids are generated inside the facade and scope is injected by it**; neither
|
|
13
|
+
is accepted from a caller.
|
|
14
|
+
* **`append_revision` is the only mutation on a revision family**, and no update
|
|
15
|
+
or delete method exists on any facade for a `*_revision` table
|
|
16
|
+
(`no-revision-update`, in both repositories).
|
|
17
|
+
|
|
18
|
+
The coverage facade and the remaining contracts §10.3 accessors arrive with the
|
|
19
|
+
tables they front, in S4 and later.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from adopt_store.api import OpenRestriction, Store, open_store, scope_facade, writer_identity
|
|
23
|
+
from adopt_store.doctor import Finding, doctor
|
|
24
|
+
from adopt_store.facades import (
|
|
25
|
+
BindingFacade,
|
|
26
|
+
IdentityFacade,
|
|
27
|
+
KnowledgeFacade,
|
|
28
|
+
ProbeFacade,
|
|
29
|
+
)
|
|
30
|
+
from adopt_store.revisions import (
|
|
31
|
+
BindingRevisionDraft,
|
|
32
|
+
IdentityRevisionDraft,
|
|
33
|
+
KnowledgeRevisionDraft,
|
|
34
|
+
ProbeDefinitionRevisionDraft,
|
|
35
|
+
RevisionWriter,
|
|
36
|
+
UnknownFamilyError,
|
|
37
|
+
)
|
|
38
|
+
from adopt_store.vector.api import VECTOR_FEATURE_FLAG, VectorIndex
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"VECTOR_FEATURE_FLAG",
|
|
42
|
+
"BindingFacade",
|
|
43
|
+
"BindingRevisionDraft",
|
|
44
|
+
"Finding",
|
|
45
|
+
"IdentityFacade",
|
|
46
|
+
"IdentityRevisionDraft",
|
|
47
|
+
"KnowledgeFacade",
|
|
48
|
+
"KnowledgeRevisionDraft",
|
|
49
|
+
"OpenRestriction",
|
|
50
|
+
"ProbeDefinitionRevisionDraft",
|
|
51
|
+
"ProbeFacade",
|
|
52
|
+
"RevisionWriter",
|
|
53
|
+
"Store",
|
|
54
|
+
"UnknownFamilyError",
|
|
55
|
+
"VectorIndex",
|
|
56
|
+
"doctor",
|
|
57
|
+
"open_store",
|
|
58
|
+
"scope_facade",
|
|
59
|
+
"writer_identity",
|
|
60
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""The runtime annex realization: contracts §12, CR-08, CR-45.
|
|
2
|
+
|
|
3
|
+
**Why this lives in `adopt-store` and not in `adopt-agent`.** `no-raw-sqlite`
|
|
4
|
+
names `adopt_agent` a source module and follows indirect chains, so the seam
|
|
5
|
+
cannot reach a driver even through a helper. `adopt-store` is the package the
|
|
6
|
+
contract permits to hold a dialect, so the annex realization is here and
|
|
7
|
+
`adopt_agent.AnnexRecords` is what the seam actually talks to -- the CR-34
|
|
8
|
+
pattern, realized **structurally**: nothing in this module imports the protocol,
|
|
9
|
+
and `test_annex.py` asserts the shapes still match.
|
|
10
|
+
|
|
11
|
+
**This is a second store, not a second schema authority.** `open_annex` opens
|
|
12
|
+
`.adopt/runtime.db`, applies `schema/annex/0001__agent_run.sql`, and never
|
|
13
|
+
touches `PRAGMA user_version` -- the annex is outside `schema_version` by
|
|
14
|
+
ratification (CR-08), and stamping it with a version number is precisely the
|
|
15
|
+
confusion that would invite someone to migrate the two together.
|
|
16
|
+
|
|
17
|
+
**It is never reachable from the export.** `adopt_export` iterates
|
|
18
|
+
`Manifest.exportable_tables()` and `agent_run` is not in the manifest at all, so
|
|
19
|
+
exclusion is by construction rather than by a filter someone could forget --
|
|
20
|
+
which is the same argument S5 recorded for the annex in the first place.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from adopt_store.annex.sqlite_annex import SqliteAnnexRecords, annex_path, open_annex
|
|
24
|
+
|
|
25
|
+
__all__ = ["SqliteAnnexRecords", "annex_path", "open_annex"]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""SQLite realization of the runtime annex. Contracts §12.
|
|
2
|
+
|
|
3
|
+
The DDL is **read from `schema/annex/0001__agent_run.sql`**, not embedded as a
|
|
4
|
+
string literal. Two reasons, and the second is the one that matters: a literal
|
|
5
|
+
would put a `CREATE TABLE` inside `packages/`, which `no-foreign-tables` forbids
|
|
6
|
+
and which CR-45 declined to route around; and a reader looking for "what is in
|
|
7
|
+
the annex" should find one answer, in a file that says so, rather than a Python
|
|
8
|
+
string that happens to be the truth today.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sqlite3
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import closing, contextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Final
|
|
16
|
+
|
|
17
|
+
from adopt_agent.annex import AgentRunRecord
|
|
18
|
+
from adopt_const import STORE_BUSY_TIMEOUT_MS
|
|
19
|
+
from adopt_schema.assets import assets_root
|
|
20
|
+
|
|
21
|
+
__all__ = ["SqliteAnnexRecords", "annex_path", "open_annex"]
|
|
22
|
+
|
|
23
|
+
#: Contracts §12: `.adopt/runtime.db` locally. Resolved beside the canonical
|
|
24
|
+
#: store so the two travel together -- an annex beside a *different* store is an
|
|
25
|
+
#: idempotency table answering for runs that were never made against it.
|
|
26
|
+
_ANNEX_FILENAME: Final[str] = "runtime.db"
|
|
27
|
+
|
|
28
|
+
#: This module used to carry its own `parents[5]` walk to the checkout, and its
|
|
29
|
+
#: comment cited `adopt_schema.manifest` and `adopt_store.api` doing the same --
|
|
30
|
+
#: three copies of one assumption that held only in a checkout (CR-53). All three
|
|
31
|
+
#: now resolve through `adopt_schema.assets`, which is also why this is looked up
|
|
32
|
+
#: per call rather than at import: the answer can now fail, and a failure at
|
|
33
|
+
#: import time would take down every command instead of the one that needs a
|
|
34
|
+
#: store.
|
|
35
|
+
|
|
36
|
+
_COLUMNS: Final[tuple[str, ...]] = (
|
|
37
|
+
"id",
|
|
38
|
+
"scope_ref",
|
|
39
|
+
"idempotency_key",
|
|
40
|
+
"skill_ref",
|
|
41
|
+
"skill_sha256",
|
|
42
|
+
"inputs_sha256",
|
|
43
|
+
"adapter",
|
|
44
|
+
"model",
|
|
45
|
+
"params_hash",
|
|
46
|
+
"status",
|
|
47
|
+
"input_tokens",
|
|
48
|
+
"output_tokens",
|
|
49
|
+
"cost_usd",
|
|
50
|
+
"wall_ms",
|
|
51
|
+
"trace_json",
|
|
52
|
+
"output_ref",
|
|
53
|
+
"created_at",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def annex_path(store_path: Path) -> Path:
|
|
58
|
+
"""Where the annex lives for a given canonical store."""
|
|
59
|
+
return store_path.parent / _ANNEX_FILENAME
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@contextmanager
|
|
63
|
+
def open_annex(path: Path, *, repo_root: Path | None = None) -> Iterator["SqliteAnnexRecords"]:
|
|
64
|
+
"""Open (creating if absent) the annex at `path` and yield its records.
|
|
65
|
+
|
|
66
|
+
A context manager rather than a bare handle, because the annex is opened for
|
|
67
|
+
the duration of one seam call; holding it open across a process makes a
|
|
68
|
+
second writer wait on a lock for no reason, and `busy_timeout` covers the
|
|
69
|
+
case where one genuinely does.
|
|
70
|
+
|
|
71
|
+
**No `user_version` is read or written.** The annex is outside
|
|
72
|
+
`schema_version` (CR-08), and `IF NOT EXISTS` in the DDL is what makes
|
|
73
|
+
opening idempotent. A version stamp here would be the first step towards
|
|
74
|
+
someone migrating the annex alongside the canonical store, which is the
|
|
75
|
+
coupling the ratification exists to prevent.
|
|
76
|
+
"""
|
|
77
|
+
root = repo_root if repo_root is not None else assets_root()
|
|
78
|
+
ddl = (root / "schema" / "annex" / "0001__agent_run.sql").read_text(encoding="utf-8")
|
|
79
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
connection = sqlite3.connect(path, isolation_level=None)
|
|
81
|
+
try:
|
|
82
|
+
connection.row_factory = sqlite3.Row
|
|
83
|
+
connection.execute(f"PRAGMA busy_timeout = {int(STORE_BUSY_TIMEOUT_MS)};")
|
|
84
|
+
connection.executescript(ddl)
|
|
85
|
+
yield SqliteAnnexRecords(connection)
|
|
86
|
+
finally:
|
|
87
|
+
connection.close()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SqliteAnnexRecords:
|
|
91
|
+
"""Realizes `adopt_agent.annex.AnnexRecords` structurally.
|
|
92
|
+
|
|
93
|
+
Two methods and no setter: contracts §12 records that a run happened, and
|
|
94
|
+
there is no operation for recording that it happened differently.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, connection: sqlite3.Connection) -> None:
|
|
98
|
+
self._connection = connection
|
|
99
|
+
|
|
100
|
+
def find_run(self, *, scope_ref: str, idempotency_key: str) -> AgentRunRecord | None:
|
|
101
|
+
"""Look up by the §12 unique index, never by key alone.
|
|
102
|
+
|
|
103
|
+
Keying on the pair matters: two engagements may legitimately choose the
|
|
104
|
+
same idempotency key, and a global key space would hand one client's
|
|
105
|
+
recorded run to another client's replay.
|
|
106
|
+
"""
|
|
107
|
+
columns = ", ".join(_COLUMNS)
|
|
108
|
+
with closing(
|
|
109
|
+
self._connection.execute(
|
|
110
|
+
f"SELECT {columns} FROM agent_run " # noqa: S608 -- names are this module's
|
|
111
|
+
"WHERE scope_ref = ? AND idempotency_key = ?;",
|
|
112
|
+
(scope_ref, idempotency_key),
|
|
113
|
+
)
|
|
114
|
+
) as cursor:
|
|
115
|
+
row = cursor.fetchone()
|
|
116
|
+
if row is None:
|
|
117
|
+
return None
|
|
118
|
+
return AgentRunRecord.model_validate({name: row[name] for name in _COLUMNS})
|
|
119
|
+
|
|
120
|
+
def record_run(self, record: AgentRunRecord) -> AgentRunRecord:
|
|
121
|
+
"""Persist a completed run and return **what is stored afterwards**.
|
|
122
|
+
|
|
123
|
+
On a race the loser gets the winner's record back rather than an error,
|
|
124
|
+
and that is the correct answer rather than a lenient one: the caller
|
|
125
|
+
asked "what is the result for this key", two callers raced to record it,
|
|
126
|
+
and idempotency means they must both be told the same thing. Raising
|
|
127
|
+
would make the second caller retry a provider call that has already been
|
|
128
|
+
paid for -- the exact double-spend the annex exists to prevent.
|
|
129
|
+
|
|
130
|
+
`ON CONFLICT DO NOTHING` followed by a read is one statement and one
|
|
131
|
+
read, so the loser observes the winner's committed row. The same shape
|
|
132
|
+
the DBOS backend's `dedupe` uses (`02` §10.2, CR-43), for the same
|
|
133
|
+
reason: the marker must be claimed atomically or it claims nothing.
|
|
134
|
+
"""
|
|
135
|
+
placeholders = ", ".join("?" for _ in _COLUMNS)
|
|
136
|
+
columns = ", ".join(_COLUMNS)
|
|
137
|
+
values = tuple(getattr(record, name) for name in _COLUMNS)
|
|
138
|
+
self._connection.execute(
|
|
139
|
+
f"INSERT INTO agent_run ({columns}) VALUES ({placeholders}) " # noqa: S608
|
|
140
|
+
"ON CONFLICT(scope_ref, idempotency_key) DO NOTHING;",
|
|
141
|
+
values,
|
|
142
|
+
)
|
|
143
|
+
stored = self.find_run(scope_ref=record.scope_ref, idempotency_key=record.idempotency_key)
|
|
144
|
+
if stored is None: # pragma: no cover -- the row was just inserted or already there
|
|
145
|
+
raise RuntimeError("agent_run vanished between insert and read")
|
|
146
|
+
return stored
|
adopt_store/api.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""`open_store` and the `Store` seam.
|
|
2
|
+
|
|
3
|
+
Implementation spec §4.7 behaviour 1 and contracts §10.3. Opening a store is
|
|
4
|
+
where two guarantees are made that everything above depends on:
|
|
5
|
+
|
|
6
|
+
**The version matrix, and why it opens rather than refuses.** A store newer than
|
|
7
|
+
the binary opens **read-only** and reports `SCHEMA_VERSION_TOO_NEW`; an older
|
|
8
|
+
store opened without `migrate` opens **read-only** and reports
|
|
9
|
+
`SCHEMA_MIGRATION_PENDING`. Neither raises, because §7.4 names exactly one
|
|
10
|
+
recovery for a bad schema deploy — *older code against the newer store* — and a
|
|
11
|
+
binary that refuses to open the store it was rolled back to have has removed the
|
|
12
|
+
only rollback surface the schema has. The condition travels on
|
|
13
|
+
`Store.restriction`, and any *write* then raises `STORE_READ_ONLY`.
|
|
14
|
+
|
|
15
|
+
**`schema_meta` is read and written on every open** (contracts §14, CR-04),
|
|
16
|
+
appended and never updated, which is what turns the table into the migration log
|
|
17
|
+
CR-04 describes. A read-only open reads it and cannot append — that is a
|
|
18
|
+
property of the file being read-only, not a branch anyone chose, and pretending
|
|
19
|
+
otherwise would mean a reader silently mutating a store it was asked not to
|
|
20
|
+
touch.
|
|
21
|
+
|
|
22
|
+
**Facades arrive with their tables.** §10.3 declares eleven accessors. `scope()`
|
|
23
|
+
came with S2's tables; `identities()`, `items()`, `bindings()`, `probes()` and
|
|
24
|
+
`revisions()` arrived at S3 with the identity and revision families; `sensors()`
|
|
25
|
+
came with the channel whose health gates freshness; and `boundary()` arrives with
|
|
26
|
+
the tier negotiation that first has something to declare. The remaining three --
|
|
27
|
+
`changes()`, `governance()` and `value()` -- land in the sprints that write the
|
|
28
|
+
tables they front. An accessor that raises is not a seam, it is a placeholder
|
|
29
|
+
wearing one.
|
|
30
|
+
|
|
31
|
+
**Two ports are exposed that §10.3 does not declare**, and deliberately so:
|
|
32
|
+
`coverage_records()` and `freshness_records()` are the storage halves of
|
|
33
|
+
`adopt_coverage` and `adopt_freshness`, which declare their own protocols and
|
|
34
|
+
import nothing from here. They are not facades and are not on the `Store` seam --
|
|
35
|
+
a caller reaching for coverage calls `recompute_coverage`, not a records object.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from collections.abc import Callable
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from importlib import metadata
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import TYPE_CHECKING, Final, Protocol, cast
|
|
43
|
+
|
|
44
|
+
from adopt_const import (
|
|
45
|
+
EXPORT_VERSION,
|
|
46
|
+
MAX_SUPPORTED_SCHEMA_VERSION,
|
|
47
|
+
MIN_SUPPORTED_SCHEMA_VERSION,
|
|
48
|
+
SCHEMA_VERSION,
|
|
49
|
+
)
|
|
50
|
+
from adopt_obs import AdoptError, Clock, ErrorCode
|
|
51
|
+
from adopt_schema.assets import assets_root
|
|
52
|
+
from adopt_schema.migrate import apply as apply_migrations
|
|
53
|
+
from adopt_scope import ScopeFacade
|
|
54
|
+
from adopt_store.facades.boundary import BoundaryFacade
|
|
55
|
+
from adopt_store.facades.identity import IdentityFacade
|
|
56
|
+
from adopt_store.facades.knowledge import BindingFacade, KnowledgeFacade, ProbeFacade
|
|
57
|
+
from adopt_store.facades.records import RevisionRecords
|
|
58
|
+
from adopt_store.facades.sensors import SensorFacade
|
|
59
|
+
from adopt_store.revisions import RevisionWriter
|
|
60
|
+
from adopt_store.sqlite.records import (
|
|
61
|
+
SqliteBindingRecords,
|
|
62
|
+
SqliteBoundaryRecords,
|
|
63
|
+
SqliteCoverageRecords,
|
|
64
|
+
SqliteExportRecords,
|
|
65
|
+
SqliteFreshnessRecords,
|
|
66
|
+
SqliteIdentityRecords,
|
|
67
|
+
SqliteImportRecords,
|
|
68
|
+
SqliteKnowledgeRecords,
|
|
69
|
+
SqliteProbeRecords,
|
|
70
|
+
SqliteRevisionRecords,
|
|
71
|
+
SqliteScopeRecords,
|
|
72
|
+
SqliteSensorRecords,
|
|
73
|
+
)
|
|
74
|
+
from adopt_store.sqlite.store import SqliteStore
|
|
75
|
+
|
|
76
|
+
__all__ = ["OpenRestriction", "Store", "open_store", "scope_facade", "writer_identity"]
|
|
77
|
+
|
|
78
|
+
if TYPE_CHECKING: # pragma: no cover -- import cycle: doctor reads this module
|
|
79
|
+
from adopt_store.doctor import Finding
|
|
80
|
+
|
|
81
|
+
_DISTRIBUTION: Final[str] = "adopt-store"
|
|
82
|
+
|
|
83
|
+
#: The tables `adopt store info` reports on, in a fixed order so two runs over
|
|
84
|
+
#: one store produce one report.
|
|
85
|
+
COUNTED_TABLES: Final[tuple[str, ...]] = (
|
|
86
|
+
"firm",
|
|
87
|
+
"engagement",
|
|
88
|
+
"system",
|
|
89
|
+
"environment",
|
|
90
|
+
"identity",
|
|
91
|
+
"identity_revision",
|
|
92
|
+
"knowledge_item",
|
|
93
|
+
"knowledge_revision",
|
|
94
|
+
"binding",
|
|
95
|
+
"binding_revision",
|
|
96
|
+
"probe_definition",
|
|
97
|
+
"probe_definition_revision",
|
|
98
|
+
"sensor",
|
|
99
|
+
"observability_boundary",
|
|
100
|
+
)
|
|
101
|
+
_SQLITE_DIALECT: Final[str] = "sqlite"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def scope_facade(store: SqliteStore, *, clock: Clock | None = None) -> ScopeFacade:
|
|
105
|
+
"""Bind the scope facade to a SQLite store.
|
|
106
|
+
|
|
107
|
+
The assembly lives here rather than in `adopt_store.facades` because that
|
|
108
|
+
package may not reach the driver even transitively (`no-raw-sqlite`), and
|
|
109
|
+
binding a facade to a realization is exactly such a reach. The facade
|
|
110
|
+
generates every id and accepts none, so there is no argument through which a
|
|
111
|
+
caller could supply one (contracts §10.3).
|
|
112
|
+
"""
|
|
113
|
+
return ScopeFacade(SqliteScopeRecords(store), clock=clock)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def writer_identity() -> str:
|
|
117
|
+
"""What lands in `schema_meta.written_by`, e.g. ``adopt-core/0.3.0``.
|
|
118
|
+
|
|
119
|
+
Honest stamping: a development checkout that cannot resolve its own version
|
|
120
|
+
says so rather than claiming a release number it does not have.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
version = metadata.version(_DISTRIBUTION)
|
|
124
|
+
except metadata.PackageNotFoundError: # pragma: no cover -- source checkout only
|
|
125
|
+
version = "0.0.0+unknown"
|
|
126
|
+
return f"adopt-core/{version}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class OpenRestriction:
|
|
131
|
+
"""Why a store opened read-only, in the vocabulary of contracts §13."""
|
|
132
|
+
|
|
133
|
+
code: ErrorCode
|
|
134
|
+
reason: str
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Store(Protocol):
|
|
138
|
+
"""The store seam, **exactly** the contracts §10.3 accessors this sprint provides.
|
|
139
|
+
|
|
140
|
+
`revision_records()` is deliberately absent even though the handle has one:
|
|
141
|
+
§10.3 declares the accessors a caller gets, and the append-only port is not
|
|
142
|
+
one of them. `doctor` needs it, so `doctor` declares its own one-method
|
|
143
|
+
protocol and takes the handle structurally -- which keeps the need visible
|
|
144
|
+
without widening the seam every future caller programs against.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
read_only: bool
|
|
148
|
+
restriction: OpenRestriction | None
|
|
149
|
+
schema_version: int
|
|
150
|
+
|
|
151
|
+
def scope(self) -> ScopeFacade: ...
|
|
152
|
+
def identities(self) -> IdentityFacade: ...
|
|
153
|
+
def items(self) -> KnowledgeFacade: ...
|
|
154
|
+
def bindings(self) -> BindingFacade: ...
|
|
155
|
+
def probes(self) -> ProbeFacade: ...
|
|
156
|
+
def sensors(self) -> SensorFacade: ...
|
|
157
|
+
def boundary(self) -> BoundaryFacade: ...
|
|
158
|
+
def revisions(self) -> RevisionWriter: ...
|
|
159
|
+
def close(self) -> None: ...
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(slots=True)
|
|
163
|
+
class SqliteStoreHandle:
|
|
164
|
+
"""A `Store` over SQLite. Returned by `open_store`; never constructed directly.
|
|
165
|
+
|
|
166
|
+
Facades are built once and cached, because each holds a records object over
|
|
167
|
+
the one connection this handle owns. Two `RevisionWriter`s over one store
|
|
168
|
+
would be two clocks and two transaction depths, and the second is how a
|
|
169
|
+
nested transaction silently commits half of someone else's work.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
backend: SqliteStore
|
|
173
|
+
read_only: bool
|
|
174
|
+
restriction: OpenRestriction | None
|
|
175
|
+
schema_version: int
|
|
176
|
+
clock: Clock | None = None
|
|
177
|
+
_facades: dict[str, object] = field(default_factory=dict)
|
|
178
|
+
|
|
179
|
+
def _cached[TFacade](self, name: str, build: Callable[[], TFacade]) -> TFacade:
|
|
180
|
+
cached = self._facades.get(name)
|
|
181
|
+
if cached is None:
|
|
182
|
+
cached = build()
|
|
183
|
+
self._facades[name] = cached
|
|
184
|
+
return cast("TFacade", cached)
|
|
185
|
+
|
|
186
|
+
def scope(self) -> ScopeFacade:
|
|
187
|
+
return self._cached(
|
|
188
|
+
"scope", lambda: ScopeFacade(SqliteScopeRecords(self.backend), clock=self.clock)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def revision_records(self) -> RevisionRecords:
|
|
192
|
+
"""The append-only port, for `doctor` and for the revision writer."""
|
|
193
|
+
return self._cached("revision_records", lambda: SqliteRevisionRecords(self.backend))
|
|
194
|
+
|
|
195
|
+
def revisions(self) -> RevisionWriter:
|
|
196
|
+
return self._cached(
|
|
197
|
+
"revisions",
|
|
198
|
+
lambda: RevisionWriter(
|
|
199
|
+
self.revision_records(),
|
|
200
|
+
SqliteKnowledgeRecords(self.backend),
|
|
201
|
+
clock=self.clock,
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def identities(self) -> IdentityFacade:
|
|
206
|
+
return self._cached(
|
|
207
|
+
"identities",
|
|
208
|
+
lambda: IdentityFacade(
|
|
209
|
+
SqliteIdentityRecords(self.backend), self.revisions(), clock=self.clock
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def items(self) -> KnowledgeFacade:
|
|
214
|
+
return self._cached(
|
|
215
|
+
"items",
|
|
216
|
+
lambda: KnowledgeFacade(SqliteKnowledgeRecords(self.backend), self.revisions()),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def bindings(self) -> BindingFacade:
|
|
220
|
+
return self._cached(
|
|
221
|
+
"bindings",
|
|
222
|
+
lambda: BindingFacade(
|
|
223
|
+
SqliteBindingRecords(self.backend), self.revisions(), clock=self.clock
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def probes(self) -> ProbeFacade:
|
|
228
|
+
return self._cached(
|
|
229
|
+
"probes",
|
|
230
|
+
lambda: ProbeFacade(
|
|
231
|
+
SqliteProbeRecords(self.backend), self.revisions(), clock=self.clock
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def sensors(self) -> SensorFacade:
|
|
236
|
+
return self._cached(
|
|
237
|
+
"sensors", lambda: SensorFacade(SqliteSensorRecords(self.backend), clock=self.clock)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def boundary(self) -> BoundaryFacade:
|
|
241
|
+
return self._cached(
|
|
242
|
+
"boundary",
|
|
243
|
+
lambda: BoundaryFacade(SqliteBoundaryRecords(self.backend), clock=self.clock),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def coverage_records(self) -> SqliteCoverageRecords:
|
|
247
|
+
"""The read port `adopt_coverage.recompute_coverage` runs on."""
|
|
248
|
+
return self._cached("coverage_records", lambda: SqliteCoverageRecords(self.backend))
|
|
249
|
+
|
|
250
|
+
def freshness_records(self) -> SqliteFreshnessRecords:
|
|
251
|
+
"""The read port `adopt_freshness.resolve_freshness` runs on."""
|
|
252
|
+
return self._cached("freshness_records", lambda: SqliteFreshnessRecords(self.backend))
|
|
253
|
+
|
|
254
|
+
def sensor_records(self) -> SqliteSensorRecords:
|
|
255
|
+
"""The sensor port, for `doctor`'s NULL-cadence finding."""
|
|
256
|
+
return self._cached("sensor_records", lambda: SqliteSensorRecords(self.backend))
|
|
257
|
+
|
|
258
|
+
def export_records(self) -> SqliteExportRecords:
|
|
259
|
+
"""The read port `adopt_export.write_bundle` runs on."""
|
|
260
|
+
return self._cached("export_records", lambda: SqliteExportRecords(self.backend))
|
|
261
|
+
|
|
262
|
+
def import_records(self) -> SqliteImportRecords:
|
|
263
|
+
"""The write port `adopt_export.apply_bundle` runs on."""
|
|
264
|
+
return self._cached("import_records", lambda: SqliteImportRecords(self.backend))
|
|
265
|
+
|
|
266
|
+
def transaction(self) -> object:
|
|
267
|
+
"""The shared transaction boundary (contracts §10.3)."""
|
|
268
|
+
return self.backend.transaction()
|
|
269
|
+
|
|
270
|
+
def counts(self) -> dict[str, int]:
|
|
271
|
+
"""Row counts for the tables Build 0 writes -- `adopt store info`'s payload.
|
|
272
|
+
|
|
273
|
+
Here rather than in the CLI because it is SQL, and CR-36's whole argument
|
|
274
|
+
for exempting one wiring module is that the CLI holds **no** dialect
|
|
275
|
+
knowledge. A second realization answers the same question its own way.
|
|
276
|
+
|
|
277
|
+
Deliberately not all 37 tables: a count of zero for a table no code
|
|
278
|
+
populates is noise that hides the row that matters, and items 8-12 extend
|
|
279
|
+
this tuple as they gain writers.
|
|
280
|
+
"""
|
|
281
|
+
counted: dict[str, int] = {}
|
|
282
|
+
for table in COUNTED_TABLES:
|
|
283
|
+
# The names are this module's own constant, never caller input.
|
|
284
|
+
rows = self.backend.query(f"SELECT COUNT(*) AS n FROM {table}") # noqa: S608
|
|
285
|
+
counted[table] = int(rows[0]["n"]) if rows else 0
|
|
286
|
+
return counted
|
|
287
|
+
|
|
288
|
+
def doctor(self) -> list["Finding"]:
|
|
289
|
+
"""Every finding in this store, reported and never repaired.
|
|
290
|
+
|
|
291
|
+
On the handle so the CLI can ask without importing `adopt_store` -- the
|
|
292
|
+
composition root is `adopt_cli.store_option` and nothing else (CR-36),
|
|
293
|
+
and `store doctor` is one of the two commands that exemption exists for.
|
|
294
|
+
"""
|
|
295
|
+
from adopt_store.doctor import doctor as run_doctor
|
|
296
|
+
|
|
297
|
+
return run_doctor(self)
|
|
298
|
+
|
|
299
|
+
def close(self) -> None:
|
|
300
|
+
self.backend.close()
|
|
301
|
+
|
|
302
|
+
def __enter__(self) -> "SqliteStoreHandle":
|
|
303
|
+
return self
|
|
304
|
+
|
|
305
|
+
def __exit__(self, *_exc: object) -> None:
|
|
306
|
+
self.close()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _restriction_for(version: int) -> OpenRestriction | None:
|
|
310
|
+
if version > MAX_SUPPORTED_SCHEMA_VERSION:
|
|
311
|
+
return OpenRestriction(
|
|
312
|
+
ErrorCode.SCHEMA_VERSION_TOO_NEW,
|
|
313
|
+
f"the store is at schema version {version} and this binary supports "
|
|
314
|
+
f"at most {MAX_SUPPORTED_SCHEMA_VERSION}",
|
|
315
|
+
)
|
|
316
|
+
if version < MIN_SUPPORTED_SCHEMA_VERSION:
|
|
317
|
+
return OpenRestriction(
|
|
318
|
+
ErrorCode.SCHEMA_MIGRATION_PENDING,
|
|
319
|
+
f"the store is at schema version {version} and this binary needs at "
|
|
320
|
+
f"least {MIN_SUPPORTED_SCHEMA_VERSION}; re-open with migrate=True",
|
|
321
|
+
)
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def open_store(
|
|
326
|
+
path: Path | str,
|
|
327
|
+
*,
|
|
328
|
+
migrate: bool = False,
|
|
329
|
+
read_only: bool = False,
|
|
330
|
+
repo_root: Path | None = None,
|
|
331
|
+
clock: Clock | None = None,
|
|
332
|
+
) -> SqliteStoreHandle:
|
|
333
|
+
"""Open a store, applying the version matrix in implementation spec §4.7.1.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
path: The store file.
|
|
337
|
+
migrate: Create or upgrade the store to `SCHEMA_VERSION` first.
|
|
338
|
+
read_only: Open for reading only. Never creates and never migrates.
|
|
339
|
+
repo_root: Where `schema/migrations/` lives. Defaults to the packaged
|
|
340
|
+
location, and is injected by tests working against a scratch tree.
|
|
341
|
+
clock: Injected clock; tests pass `ManualClock`.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
A handle carrying `read_only`, `restriction` and `schema_version`.
|
|
345
|
+
|
|
346
|
+
Raises:
|
|
347
|
+
AdoptError: ``SCHEMA_MIGRATION_PENDING`` when asked to open a store that
|
|
348
|
+
does not exist without `migrate`. ``SCHEMA_MIGRATION_FAILED`` when a
|
|
349
|
+
migration fails; the store is left exactly as it was found.
|
|
350
|
+
"""
|
|
351
|
+
store_path = Path(path)
|
|
352
|
+
|
|
353
|
+
if read_only and migrate:
|
|
354
|
+
raise AdoptError(
|
|
355
|
+
ErrorCode.STORE_READ_ONLY,
|
|
356
|
+
message="a read-only open cannot migrate",
|
|
357
|
+
hint="Migrating is a write. Choose one: open read-only to read a store as "
|
|
358
|
+
"it is, or open for writing with migrate=True to bring it to the "
|
|
359
|
+
"current schema version.",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
if not store_path.exists() and not migrate:
|
|
363
|
+
# Deliberately checked before opening: a read-write connect would create
|
|
364
|
+
# an empty file, and an empty file that is not a store is worse than no
|
|
365
|
+
# file at all -- the next open finds something and has to decide what.
|
|
366
|
+
raise AdoptError(
|
|
367
|
+
ErrorCode.SCHEMA_MIGRATION_PENDING,
|
|
368
|
+
message=f"no store exists at {store_path}",
|
|
369
|
+
hint="Pass migrate=True to create schema version 3.",
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if read_only:
|
|
373
|
+
backend = SqliteStore(store_path, read_only=True, clock=clock)
|
|
374
|
+
version = backend.current_version()
|
|
375
|
+
restriction = _restriction_for(version)
|
|
376
|
+
if restriction is not None:
|
|
377
|
+
backend.read_only_reason = restriction.reason
|
|
378
|
+
else:
|
|
379
|
+
backend.read_only_reason = "the caller opened it read-only"
|
|
380
|
+
return SqliteStoreHandle(
|
|
381
|
+
backend=backend,
|
|
382
|
+
read_only=True,
|
|
383
|
+
restriction=restriction,
|
|
384
|
+
schema_version=version,
|
|
385
|
+
clock=clock,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
backend = SqliteStore(store_path, clock=clock)
|
|
389
|
+
version = backend.current_version()
|
|
390
|
+
|
|
391
|
+
if version > MAX_SUPPORTED_SCHEMA_VERSION:
|
|
392
|
+
backend.close()
|
|
393
|
+
return _reopen_read_only(store_path, version, clock)
|
|
394
|
+
|
|
395
|
+
if migrate and (version < SCHEMA_VERSION or backend.is_empty()):
|
|
396
|
+
root = repo_root if repo_root is not None else _packaged_repo_root()
|
|
397
|
+
# `apply` appends the `schema_meta` row for every file it runs, so a
|
|
398
|
+
# freshly created store already carries its open record.
|
|
399
|
+
applied = apply_migrations(backend, root, _SQLITE_DIALECT, writer_identity())
|
|
400
|
+
version = backend.current_version()
|
|
401
|
+
if applied:
|
|
402
|
+
return SqliteStoreHandle(
|
|
403
|
+
backend=backend,
|
|
404
|
+
read_only=False,
|
|
405
|
+
restriction=None,
|
|
406
|
+
schema_version=version,
|
|
407
|
+
clock=clock,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
if version < MIN_SUPPORTED_SCHEMA_VERSION:
|
|
411
|
+
backend.close()
|
|
412
|
+
return _reopen_read_only(store_path, version, clock)
|
|
413
|
+
|
|
414
|
+
backend.append_schema_meta(SCHEMA_VERSION, EXPORT_VERSION, writer_identity())
|
|
415
|
+
return SqliteStoreHandle(
|
|
416
|
+
backend=backend, read_only=False, restriction=None, schema_version=version, clock=clock
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _reopen_read_only(path: Path, version: int, clock: Clock | None) -> SqliteStoreHandle:
|
|
421
|
+
restriction = _restriction_for(version)
|
|
422
|
+
assert restriction is not None # noqa: S101 -- only reached for an out-of-range version
|
|
423
|
+
backend = SqliteStore(path, read_only=True, read_only_reason=restriction.reason, clock=clock)
|
|
424
|
+
return SqliteStoreHandle(
|
|
425
|
+
backend=backend,
|
|
426
|
+
read_only=True,
|
|
427
|
+
restriction=restriction,
|
|
428
|
+
schema_version=version,
|
|
429
|
+
clock=clock,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _packaged_repo_root() -> Path:
|
|
434
|
+
"""The root holding `schema/`, wherever this artefact carries it.
|
|
435
|
+
|
|
436
|
+
This function used to walk five parents up from `__file__` and its docstring
|
|
437
|
+
claimed that avoided "the failure that only shows up after release". **It
|
|
438
|
+
named the failure it did not prevent** (CR-53): from an installed wheel the
|
|
439
|
+
walk leaves `site-packages` and lands on the environment root, which holds no
|
|
440
|
+
`schema/`, and the migrations glob then found nothing and reported nothing
|
|
441
|
+
pending. `adopt_schema.assets` resolves it in one place and raises
|
|
442
|
+
`SCHEMA_ASSETS_MISSING` when it genuinely cannot be found.
|
|
443
|
+
"""
|
|
444
|
+
return assets_root()
|