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,273 @@
1
+ """Revisions, their ordered parents and generation numbers (design 6.4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Sequence
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ from sqlalchemy import Table, insert, select, text
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+ from sqlalchemy.orm import Session
13
+
14
+ from lorebase_core.revision import (
15
+ Revision,
16
+ UnknownRevisionError,
17
+ next_generation,
18
+ revision_id,
19
+ )
20
+ from lorebase_store.models import metadata
21
+
22
+ REVISIONS: Table = metadata.tables["revisions"]
23
+ REVISION_PARENTS: Table = metadata.tables["revision_parents"]
24
+
25
+ _FIRST_PARENT_CHAIN = text(
26
+ """
27
+ WITH RECURSIVE chain(id, depth) AS (
28
+ SELECT CAST(:head AS bytea), 0
29
+ UNION ALL
30
+ SELECT p.parent_id, chain.depth + 1
31
+ FROM chain
32
+ JOIN revision_parents p
33
+ ON p.domain_id = :domain AND p.revision_id = chain.id AND p.position = 0
34
+ )
35
+ SELECT id FROM chain ORDER BY depth
36
+ """
37
+ )
38
+
39
+
40
+ _CONTAINED = text(
41
+ """
42
+ WITH RECURSIVE walk(id) AS (
43
+ SELECT CAST(:head AS bytea)
44
+ UNION
45
+ SELECT p.parent_id
46
+ FROM walk
47
+ JOIN revision_parents p ON p.domain_id = :domain AND p.revision_id = walk.id
48
+ JOIN revisions r
49
+ ON r.domain_id = :domain AND r.id = p.parent_id AND r.generation >= :generation
50
+ )
51
+ SELECT id FROM walk WHERE id = ANY(CAST(:candidates AS bytea[]))
52
+ """
53
+ )
54
+ """Candidates reachable from a head; the walk stops below the lowest candidate generation."""
55
+
56
+
57
+ _AHEAD_BEHIND = text(
58
+ """
59
+ WITH RECURSIVE
60
+ left_side(id) AS (
61
+ SELECT CAST(:left AS bytea)
62
+ UNION
63
+ SELECT p.parent_id
64
+ FROM left_side
65
+ JOIN revision_parents p ON p.domain_id = :domain AND p.revision_id = left_side.id
66
+ ),
67
+ right_side(id) AS (
68
+ SELECT CAST(:right AS bytea)
69
+ UNION
70
+ SELECT p.parent_id
71
+ FROM right_side
72
+ JOIN revision_parents p ON p.domain_id = :domain AND p.revision_id = right_side.id
73
+ )
74
+ SELECT
75
+ (SELECT count(*) FROM left_side WHERE id NOT IN (SELECT id FROM right_side)),
76
+ (SELECT count(*) FROM right_side WHERE id NOT IN (SELECT id FROM left_side))
77
+ """
78
+ )
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class RevisionRow:
83
+ id: bytes
84
+ root: bytes
85
+ generation: int
86
+ parents: tuple[bytes, ...]
87
+ author: str
88
+ on_behalf_of: str | None
89
+ message: str
90
+ origin: dict[str, Any]
91
+ stats: dict[str, Any]
92
+ created_at: datetime
93
+
94
+
95
+ class RevisionRepository:
96
+ """Revisions of one Domain."""
97
+
98
+ def __init__(self, session: AsyncSession, domain_id: str) -> None:
99
+ self._session = session
100
+ self._domain = domain_id
101
+
102
+ async def insert(self, revision: Revision) -> RevisionRow:
103
+ """Store a Revision whose parents are stored; returns it with id and generation."""
104
+ if revision.domain != self._domain:
105
+ raise ValueError(f"Revision of Domain {revision.domain!r} in {self._domain!r}")
106
+ generations = await self.generations(revision.parents)
107
+ missing = [p for p in revision.parents if p not in generations]
108
+ if missing:
109
+ raise UnknownRevisionError(missing[0].hex())
110
+ row = RevisionRow(
111
+ id=revision_id(revision),
112
+ root=revision.root,
113
+ generation=next_generation(generations.values()),
114
+ parents=tuple(revision.parents),
115
+ author=revision.author.principal,
116
+ on_behalf_of=revision.author.on_behalf_of,
117
+ message=revision.message,
118
+ origin=revision.origin.model_dump(),
119
+ stats=revision.stats.model_dump(),
120
+ created_at=revision.created_at,
121
+ )
122
+ await self._session.execute(
123
+ insert(REVISIONS).values(
124
+ domain_id=self._domain,
125
+ id=row.id,
126
+ root=row.root,
127
+ generation=row.generation,
128
+ author=row.author,
129
+ on_behalf_of=row.on_behalf_of,
130
+ message=row.message,
131
+ origin=row.origin,
132
+ stats=row.stats,
133
+ created_at=row.created_at,
134
+ )
135
+ )
136
+ if row.parents:
137
+ await self._session.execute(
138
+ insert(REVISION_PARENTS),
139
+ [
140
+ {
141
+ "domain_id": self._domain,
142
+ "revision_id": row.id,
143
+ "position": position,
144
+ "parent_id": parent,
145
+ }
146
+ for position, parent in enumerate(row.parents)
147
+ ],
148
+ )
149
+ return row
150
+
151
+ async def get(self, revision: bytes) -> RevisionRow | None:
152
+ result = await self._session.execute(
153
+ select(REVISIONS).where(
154
+ REVISIONS.c.domain_id == self._domain, REVISIONS.c.id == revision
155
+ )
156
+ )
157
+ found = result.mappings().one_or_none()
158
+ if found is None:
159
+ return None
160
+ parents = await self.parents(revision)
161
+ values = {k: v for k, v in found.items() if k != "domain_id"}
162
+ return RevisionRow(parents=parents, **values)
163
+
164
+ async def require(self, revision: bytes) -> RevisionRow:
165
+ found = await self.get(revision)
166
+ if found is None:
167
+ raise UnknownRevisionError(revision.hex())
168
+ return found
169
+
170
+ async def parents(self, revision: bytes) -> tuple[bytes, ...]:
171
+ result = await self._session.execute(
172
+ select(REVISION_PARENTS.c.parent_id)
173
+ .where(
174
+ REVISION_PARENTS.c.domain_id == self._domain,
175
+ REVISION_PARENTS.c.revision_id == revision,
176
+ )
177
+ .order_by(REVISION_PARENTS.c.position)
178
+ )
179
+ return tuple(result.scalars())
180
+
181
+ async def generations(self, revisions: Iterable[bytes]) -> dict[bytes, int]:
182
+ wanted = list(dict.fromkeys(revisions))
183
+ if not wanted:
184
+ return {}
185
+ result = await self._session.execute(
186
+ select(REVISIONS.c.id, REVISIONS.c.generation).where(
187
+ REVISIONS.c.domain_id == self._domain, REVISIONS.c.id.in_(wanted)
188
+ )
189
+ )
190
+ return {rev: int(generation) for rev, generation in result}
191
+
192
+ async def ahead_behind(self, left: bytes, right: bytes) -> tuple[int, int]:
193
+ """Revisions reachable from `left` but not from `right`, and the other way round."""
194
+ if left == right:
195
+ return 0, 0
196
+ result = await self._session.execute(
197
+ _AHEAD_BEHIND, {"left": left, "right": right, "domain": self._domain}
198
+ )
199
+ ahead, behind = result.one()
200
+ return int(ahead), int(behind)
201
+
202
+ async def contained(self, head: bytes, candidates: Iterable[bytes]) -> set[bytes]:
203
+ """The candidates reachable from `head` (`head` included): its ancestors or itself.
204
+ Only Revisions at least as recent (by generation) as the oldest candidate are
205
+ walked."""
206
+ generations = await self.generations(candidates)
207
+ if not generations:
208
+ return set()
209
+ result = await self._session.execute(
210
+ _CONTAINED,
211
+ {
212
+ "head": head,
213
+ "domain": self._domain,
214
+ "generation": min(generations.values()),
215
+ "candidates": list(generations),
216
+ },
217
+ )
218
+ return set(result.scalars())
219
+
220
+ async def first_parent_chain(self, head: bytes) -> list[bytes]:
221
+ """`head` and its first-parent ancestors, newest first."""
222
+ result = await self._session.execute(
223
+ _FIRST_PARENT_CHAIN, {"head": head, "domain": self._domain}
224
+ )
225
+ return list(result.scalars())
226
+
227
+
228
+ class PgRevisionGraph:
229
+ """Synchronous core `RevisionGraph` over a synchronous `Session` (see `PgNodeStore`)."""
230
+
231
+ def __init__(self, session: Session, domain_id: str) -> None:
232
+ self._session = session
233
+ self._domain = domain_id
234
+ self._parents: dict[bytes, tuple[bytes, ...]] = {}
235
+ self._generations: dict[bytes, int] = {}
236
+ self._roots: dict[bytes, bytes] = {}
237
+
238
+ def parents(self, revision_id: bytes) -> Sequence[bytes]:
239
+ if revision_id not in self._parents:
240
+ self._load(revision_id)
241
+ return self._parents[revision_id]
242
+
243
+ def generation(self, revision_id: bytes) -> int:
244
+ if revision_id not in self._generations:
245
+ self._load(revision_id)
246
+ return self._generations[revision_id]
247
+
248
+ def root(self, revision_id: bytes) -> bytes:
249
+ """Tree root of a Revision (the `revision_root` callable of a merge input)."""
250
+ if revision_id not in self._roots:
251
+ self._load(revision_id)
252
+ return self._roots[revision_id]
253
+
254
+ def _load(self, revision: bytes) -> None:
255
+ found = self._session.execute(
256
+ select(REVISIONS.c.generation, REVISIONS.c.root).where(
257
+ REVISIONS.c.domain_id == self._domain, REVISIONS.c.id == revision
258
+ )
259
+ ).one_or_none()
260
+ if found is None:
261
+ raise UnknownRevisionError(revision.hex())
262
+ generation, root = found
263
+ self._roots[revision] = root
264
+ parents = self._session.execute(
265
+ select(REVISION_PARENTS.c.parent_id)
266
+ .where(
267
+ REVISION_PARENTS.c.domain_id == self._domain,
268
+ REVISION_PARENTS.c.revision_id == revision,
269
+ )
270
+ .order_by(REVISION_PARENTS.c.position)
271
+ ).scalars()
272
+ self._generations[revision] = int(generation)
273
+ self._parents[revision] = tuple(parents)
@@ -0,0 +1,33 @@
1
+ """Row-level security scope of the current transaction (store schema notes, RLS)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlalchemy import text
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from lorebase_store.auth.scope import checked_domain_id
9
+
10
+ DOMAIN_IDS_SETTING = "lorebase.domain_ids"
11
+ """Transaction setting listing, comma-separated, the Domains the transaction may touch."""
12
+
13
+ _ADD_DOMAIN = text(
14
+ """
15
+ SELECT set_config(
16
+ :name,
17
+ CASE
18
+ WHEN coalesce(current_setting(:name, true), '') = '' THEN :domain
19
+ WHEN :domain = ANY(string_to_array(current_setting(:name, true), ','))
20
+ THEN current_setting(:name, true)
21
+ ELSE current_setting(:name, true) || ',' || :domain
22
+ END,
23
+ true
24
+ )
25
+ """
26
+ )
27
+
28
+
29
+ async def scope_to_domain(session: AsyncSession, domain_id: str) -> None:
30
+ """Add `domain_id` to the Domains visible in the current transaction."""
31
+ await session.execute(
32
+ _ADD_DOMAIN, {"name": DOMAIN_IDS_SETTING, "domain": checked_domain_id(domain_id)}
33
+ )
@@ -0,0 +1,123 @@
1
+ """Synchronization runs of structured Sources (design 7.4, 9.1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ from sqlalchemy import Table, and_, insert, or_, select, update
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from lorebase_store.models import metadata
13
+ from lorebase_store.models.enums import RunStatus
14
+
15
+ SOURCE_RUNS: Table = metadata.tables["source_runs"]
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class SourceRunRow:
20
+ id: str
21
+ source_id: str
22
+ status: RunStatus
23
+ started_at: datetime | None
24
+ finished_at: datetime | None
25
+ stats: dict[str, Any]
26
+ revision_id: bytes | None = None
27
+ proposal_id: str | None = None
28
+ error: dict[str, Any] | None = None
29
+
30
+
31
+ def _row(found: Any) -> SourceRunRow:
32
+ return SourceRunRow(**{k: v for k, v in found.items() if k != "domain_id"})
33
+
34
+
35
+ class SourceRunRepository:
36
+ """Source runs of one Domain, in the caller's transaction (after `scope_to_domain`)."""
37
+
38
+ def __init__(self, session: AsyncSession, domain_id: str) -> None:
39
+ self._session = session
40
+ self._domain = domain_id
41
+
42
+ async def insert(self, row: SourceRunRow) -> None:
43
+ await self._session.execute(
44
+ insert(SOURCE_RUNS).values(
45
+ domain_id=self._domain,
46
+ id=row.id,
47
+ source_id=row.source_id,
48
+ status=row.status,
49
+ started_at=row.started_at,
50
+ finished_at=row.finished_at,
51
+ stats=row.stats,
52
+ revision_id=row.revision_id,
53
+ proposal_id=row.proposal_id,
54
+ error=row.error,
55
+ )
56
+ )
57
+
58
+ async def get(self, run_id: str) -> SourceRunRow | None:
59
+ found = (
60
+ (
61
+ await self._session.execute(
62
+ select(SOURCE_RUNS).where(
63
+ SOURCE_RUNS.c.domain_id == self._domain, SOURCE_RUNS.c.id == run_id
64
+ )
65
+ )
66
+ )
67
+ .mappings()
68
+ .one_or_none()
69
+ )
70
+ return None if found is None else _row(found)
71
+
72
+ async def finish(
73
+ self,
74
+ run_id: str,
75
+ *,
76
+ status: RunStatus,
77
+ finished_at: datetime,
78
+ stats: dict[str, Any],
79
+ revision_id: bytes | None = None,
80
+ proposal_id: str | None = None,
81
+ error: dict[str, Any] | None = None,
82
+ ) -> None:
83
+ await self._session.execute(
84
+ update(SOURCE_RUNS)
85
+ .where(SOURCE_RUNS.c.domain_id == self._domain, SOURCE_RUNS.c.id == run_id)
86
+ .values(
87
+ status=status,
88
+ finished_at=finished_at,
89
+ stats=stats,
90
+ revision_id=revision_id,
91
+ proposal_id=proposal_id,
92
+ error=error,
93
+ )
94
+ )
95
+
96
+ async def latest(
97
+ self, source_id: str, *, status: RunStatus | None = None
98
+ ) -> SourceRunRow | None:
99
+ """The most recently started run of a Source (with `status`, when given)."""
100
+ query = select(SOURCE_RUNS).where(
101
+ SOURCE_RUNS.c.domain_id == self._domain, SOURCE_RUNS.c.source_id == source_id
102
+ )
103
+ if status is not None:
104
+ query = query.where(SOURCE_RUNS.c.status == status)
105
+ query = query.order_by(SOURCE_RUNS.c.started_at.desc(), SOURCE_RUNS.c.id.desc()).limit(1)
106
+ found = (await self._session.execute(query)).mappings().one_or_none()
107
+ return None if found is None else _row(found)
108
+
109
+ async def page(
110
+ self, source_id: str, *, limit: int, after: tuple[datetime, str] | None = None
111
+ ) -> list[SourceRunRow]:
112
+ """Runs of a Source, newest first, after the keyset `(started_at, id)`."""
113
+ table = SOURCE_RUNS
114
+ query = select(table).where(
115
+ table.c.domain_id == self._domain, table.c.source_id == source_id
116
+ )
117
+ if after is not None:
118
+ at, last_id = after
119
+ query = query.where(
120
+ or_(table.c.started_at < at, and_(table.c.started_at == at, table.c.id < last_id))
121
+ )
122
+ query = query.order_by(table.c.started_at.desc(), table.c.id.desc()).limit(limit)
123
+ return [_row(row) for row in (await self._session.execute(query)).mappings()]