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.
- lorebase_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- 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()]
|