lorebase-store 0.2.3__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.
Files changed (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,153 @@
1
+ """Browser sessions of the web app (design 13.2, Keeper as backend-for-frontend).
2
+
3
+ The repository stores opaque values: the session id digest and the encrypted
4
+ provider tokens are produced by Keeper. A session is found by the SHA-256 of its id.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timedelta
12
+
13
+ from sqlalchemy import delete, or_, select, update
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ from lorebase_store.models import WebSession
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class SessionRecord:
21
+ """A stored session: a digest and ciphertext only, never the session id."""
22
+
23
+ id_hash: bytes
24
+ principal_id: str
25
+ subject: str
26
+ external_groups: tuple[str, ...]
27
+ tokens: bytes | None
28
+ created_at: datetime
29
+ last_seen_at: datetime
30
+ expires_at: datetime
31
+ refresh_at: datetime | None
32
+
33
+
34
+ def _record(row: WebSession) -> SessionRecord:
35
+ return SessionRecord(
36
+ id_hash=row.id_hash,
37
+ principal_id=row.principal_id,
38
+ subject=row.subject,
39
+ external_groups=tuple(str(group) for group in row.external_groups or ()),
40
+ tokens=row.tokens,
41
+ created_at=row.created_at,
42
+ last_seen_at=row.last_seen_at,
43
+ expires_at=row.expires_at,
44
+ refresh_at=row.refresh_at,
45
+ )
46
+
47
+
48
+ class SessionRepository:
49
+ """Web sessions. The table is installation-wide: no Domain scope applies."""
50
+
51
+ def __init__(self, session: AsyncSession) -> None:
52
+ self._session = session
53
+
54
+ async def add(
55
+ self,
56
+ *,
57
+ id_hash: bytes,
58
+ principal_id: str,
59
+ subject: str,
60
+ external_groups: Sequence[str],
61
+ tokens: bytes | None,
62
+ created_at: datetime,
63
+ expires_at: datetime,
64
+ refresh_at: datetime | None,
65
+ ) -> SessionRecord:
66
+ """Insert a session first seen at `created_at`."""
67
+ row = WebSession(
68
+ id_hash=id_hash,
69
+ principal_id=principal_id,
70
+ subject=subject,
71
+ external_groups=list(external_groups),
72
+ tokens=tokens,
73
+ created_at=created_at,
74
+ last_seen_at=created_at,
75
+ expires_at=expires_at,
76
+ refresh_at=refresh_at,
77
+ )
78
+ self._session.add(row)
79
+ await self._session.flush()
80
+ return _record(row)
81
+
82
+ async def get(self, id_hash: bytes) -> SessionRecord | None:
83
+ """The session whose id has this digest."""
84
+ row = await self._session.get(WebSession, id_hash)
85
+ return _record(row) if row else None
86
+
87
+ async def touch(self, id_hash: bytes, at: datetime, *, min_interval: timedelta) -> bool:
88
+ """Record activity at `at`, unless the recorded activity is more recent than
89
+ `min_interval`; keeps busy sessions from writing on every request."""
90
+ result = await self._session.execute(
91
+ update(WebSession)
92
+ .where(WebSession.id_hash == id_hash, WebSession.last_seen_at <= at - min_interval)
93
+ .values(last_seen_at=at)
94
+ )
95
+ return bool(result.rowcount) # type: ignore[attr-defined]
96
+
97
+ async def claim_refresh(self, id_hash: bytes, now: datetime, until: datetime) -> bool:
98
+ """Take the right to refresh the provider tokens: true for one caller only while the
99
+ refresh is due, which moves the due time to `until` (a lease)."""
100
+ result = await self._session.execute(
101
+ update(WebSession)
102
+ .where(
103
+ WebSession.id_hash == id_hash,
104
+ WebSession.refresh_at.is_not(None),
105
+ WebSession.refresh_at <= now,
106
+ )
107
+ .values(refresh_at=until)
108
+ )
109
+ return bool(result.rowcount) # type: ignore[attr-defined]
110
+
111
+ async def update_tokens(
112
+ self,
113
+ id_hash: bytes,
114
+ *,
115
+ tokens: bytes | None,
116
+ external_groups: Sequence[str],
117
+ refresh_at: datetime | None,
118
+ ) -> bool:
119
+ """Store refreshed tokens and groups; false when the session is gone."""
120
+ result = await self._session.execute(
121
+ update(WebSession)
122
+ .where(WebSession.id_hash == id_hash)
123
+ .values(tokens=tokens, external_groups=list(external_groups), refresh_at=refresh_at)
124
+ )
125
+ return bool(result.rowcount) # type: ignore[attr-defined]
126
+
127
+ async def delete(self, id_hash: bytes) -> bool:
128
+ """End a session; false when it did not exist."""
129
+ result = await self._session.execute(
130
+ delete(WebSession).where(WebSession.id_hash == id_hash)
131
+ )
132
+ return bool(result.rowcount) # type: ignore[attr-defined]
133
+
134
+ async def delete_for_principal(self, principal_id: str) -> int:
135
+ """End every session of a principal; the number ended."""
136
+ result = await self._session.execute(
137
+ delete(WebSession).where(WebSession.principal_id == principal_id)
138
+ )
139
+ return int(result.rowcount) # type: ignore[attr-defined]
140
+
141
+ async def purge_expired(self, now: datetime, idle_before: datetime, *, limit: int) -> int:
142
+ """Delete up to `limit` sessions past their absolute expiry or idle since before
143
+ `idle_before`; the number deleted."""
144
+ stale = (
145
+ select(WebSession.id_hash)
146
+ .where(or_(WebSession.expires_at <= now, WebSession.last_seen_at <= idle_before))
147
+ .limit(limit)
148
+ .scalar_subquery()
149
+ )
150
+ result = await self._session.execute(
151
+ delete(WebSession).where(WebSession.id_hash.in_(stale))
152
+ )
153
+ return int(result.rowcount) # type: ignore[attr-defined]
@@ -0,0 +1,53 @@
1
+ """One transaction over the auth repositories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass
8
+
9
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
10
+
11
+ from lorebase_store.auth.api_keys import ApiKeyRepository
12
+ from lorebase_store.auth.audit import AuditRepository, AuditStream
13
+ from lorebase_store.auth.grants import GrantRepository
14
+ from lorebase_store.auth.principals import GroupRepository, PrincipalRepository
15
+ from lorebase_store.auth.sessions import SessionRepository
16
+ from lorebase_store.auth.usage import UsageRepository
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class AuthUnit:
21
+ """The auth repositories sharing one session and transaction."""
22
+
23
+ principals: PrincipalRepository
24
+ groups: GroupRepository
25
+ api_keys: ApiKeyRepository
26
+ grants: GrantRepository
27
+ audit: AuditRepository
28
+ read_audit: AuditRepository
29
+ """The read stream of the audit log (`audit_read_log`)."""
30
+ usage: UsageRepository
31
+ sessions: SessionRepository
32
+
33
+
34
+ class SqlAuthStore:
35
+ """Opens transactions over the auth repositories. Commits on success, rolls back on error."""
36
+
37
+ def __init__(self, sessions: async_sessionmaker[AsyncSession]) -> None:
38
+ self._sessions = sessions
39
+
40
+ @asynccontextmanager
41
+ async def unit(self) -> AsyncIterator[AuthUnit]:
42
+ """A transaction: leave the block normally to commit."""
43
+ async with self._sessions() as session, session.begin():
44
+ yield AuthUnit(
45
+ principals=PrincipalRepository(session),
46
+ groups=GroupRepository(session),
47
+ api_keys=ApiKeyRepository(session),
48
+ grants=GrantRepository(session),
49
+ audit=AuditRepository(session),
50
+ read_audit=AuditRepository(session, AuditStream.READS),
51
+ usage=UsageRepository(session),
52
+ sessions=SessionRepository(session),
53
+ )
@@ -0,0 +1,70 @@
1
+ """Daily usage of API keys, the counters behind per-key quotas (design 13.6).
2
+
3
+ Each key has one row per UTC day. Writers add deltas, so several Keeper processes serving the
4
+ same key sum up correctly; the row returned by an addition holds the totals of all of them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from datetime import date, datetime
11
+
12
+ from sqlalchemy import select
13
+ from sqlalchemy.dialects.postgresql import insert
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ from lorebase_store.models import ApiKeyUsage
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class UsageRecord:
21
+ """What a key used on one day."""
22
+
23
+ api_key_id: str
24
+ day: date
25
+ requests: int
26
+ context_tokens: int
27
+
28
+
29
+ class UsageRepository:
30
+ """Daily usage counters of API keys."""
31
+
32
+ def __init__(self, session: AsyncSession) -> None:
33
+ self._session = session
34
+
35
+ async def get(self, api_key_id: str, day: date) -> UsageRecord:
36
+ """The usage of a key on a day; zeros when nothing was recorded."""
37
+ row = (
38
+ await self._session.execute(
39
+ select(ApiKeyUsage.requests, ApiKeyUsage.context_tokens).where(
40
+ ApiKeyUsage.api_key_id == api_key_id, ApiKeyUsage.day == day
41
+ )
42
+ )
43
+ ).one_or_none()
44
+ requests, tokens = (0, 0) if row is None else (int(row[0]), int(row[1]))
45
+ return UsageRecord(api_key_id, day, requests, tokens)
46
+
47
+ async def add(
48
+ self, api_key_id: str, day: date, *, requests: int, context_tokens: int, at: datetime
49
+ ) -> UsageRecord:
50
+ """Add to the counters of a key and day and return the new totals.
51
+
52
+ `IntegrityError` when the key does not exist (it was deleted meanwhile).
53
+ """
54
+ insertion = insert(ApiKeyUsage).values(
55
+ api_key_id=api_key_id,
56
+ day=day,
57
+ requests=requests,
58
+ context_tokens=context_tokens,
59
+ updated_at=at,
60
+ )
61
+ statement = insertion.on_conflict_do_update(
62
+ index_elements=[ApiKeyUsage.api_key_id, ApiKeyUsage.day],
63
+ set_={
64
+ "requests": ApiKeyUsage.requests + insertion.excluded.requests,
65
+ "context_tokens": ApiKeyUsage.context_tokens + insertion.excluded.context_tokens,
66
+ "updated_at": insertion.excluded.updated_at,
67
+ },
68
+ ).returning(ApiKeyUsage.requests, ApiKeyUsage.context_tokens)
69
+ total = (await self._session.execute(statement)).one()
70
+ return UsageRecord(api_key_id, day, int(total[0]), int(total[1]))
@@ -0,0 +1,73 @@
1
+ """Blob store interface for document bytes and its default Postgres backend (design 9.2, AC-24).
2
+
3
+ Blobs are content addressed: the reference names the backend and the SHA-256 of the bytes, so
4
+ storing the same bytes twice keeps one copy. The Postgres backend writes `document_blobs` in the
5
+ caller's transaction (row-level security applies); another backend (for example an
6
+ S3-compatible store) implements the same protocol and ignores the session.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ from typing import Protocol
13
+
14
+ from sqlalchemy import Table, select
15
+ from sqlalchemy.dialects.postgresql import insert
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from lorebase_store.models import metadata
19
+
20
+ BLOBS: Table = metadata.tables["document_blobs"]
21
+ POSTGRES_SCHEME = "postgres"
22
+ TEXT_REF_KEY = "text_ref"
23
+ """Document metadata member holding the blob reference of the parsed text (UTF-8)."""
24
+
25
+
26
+ class BlobNotFoundError(LookupError):
27
+ """No blob under that reference."""
28
+
29
+
30
+ class BlobStore(Protocol):
31
+ """Content-addressed storage of document bytes."""
32
+
33
+ async def put(self, session: AsyncSession, domain_id: str, data: bytes) -> str:
34
+ """Store `data` and return its reference (idempotent per content)."""
35
+ ...
36
+
37
+ async def get(self, session: AsyncSession, domain_id: str, ref: str) -> bytes:
38
+ """The bytes stored under `ref`; raises `BlobNotFoundError`."""
39
+ ...
40
+
41
+
42
+ def sha256(data: bytes) -> bytes:
43
+ return hashlib.sha256(data).digest()
44
+
45
+
46
+ class PostgresBlobStore:
47
+ """`BlobStore` keeping the bytes in `document_blobs` (references `postgres:<hex>`)."""
48
+
49
+ async def put(self, session: AsyncSession, domain_id: str, data: bytes) -> str:
50
+ digest = sha256(data)
51
+ await session.execute(
52
+ insert(BLOBS)
53
+ .values(domain_id=domain_id, sha256=digest, content=data, size=len(data))
54
+ .on_conflict_do_nothing(index_elements=["domain_id", "sha256"])
55
+ )
56
+ return f"{POSTGRES_SCHEME}:{digest.hex()}"
57
+
58
+ async def get(self, session: AsyncSession, domain_id: str, ref: str) -> bytes:
59
+ scheme, _, digest = ref.partition(":")
60
+ if scheme != POSTGRES_SCHEME or not digest:
61
+ raise BlobNotFoundError(f"not a Postgres blob reference: {ref!r}")
62
+ try:
63
+ key = bytes.fromhex(digest)
64
+ except ValueError:
65
+ raise BlobNotFoundError(f"invalid blob reference: {ref!r}") from None
66
+ content = (
67
+ await session.execute(
68
+ select(BLOBS.c.content).where(BLOBS.c.domain_id == domain_id, BLOBS.c.sha256 == key)
69
+ )
70
+ ).scalar_one_or_none()
71
+ if content is None:
72
+ raise BlobNotFoundError(f"no blob {ref!r} in Domain {domain_id!r}")
73
+ return bytes(content)