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.
@@ -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()