lorebase-keeper 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_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
"""History and lineage of one Record on a branch (design 5.11, 6.3, 6.11).
|
|
2
|
+
|
|
3
|
+
Both read the branch history table (`mat_history`): one row per change of an element along
|
|
4
|
+
the first-parent chain of the branch, keyed by `(domain, branch, partition, element_key,
|
|
5
|
+
revision)`, so the changes of one Record are an index range of that key. Neither walks the
|
|
6
|
+
Revision graph nor scans changesets: the cost grows with the number of changes of the Record,
|
|
7
|
+
not with the length of the branch. Overlay branches read their own rows and their base's up to
|
|
8
|
+
the fork point (`BranchLayout.history`).
|
|
9
|
+
|
|
10
|
+
- History: the Revisions that changed the Record, newest first by generation, paged with a
|
|
11
|
+
`(generation, revision)` keyset. Each change says whether it created, updated or deleted the
|
|
12
|
+
Record, which Attributes it changed (the stored objects before and after are compared) with
|
|
13
|
+
the provenance of each, and on request the values before and after.
|
|
14
|
+
- Lineage: per Attribute holding a value, the provenance of its last change
|
|
15
|
+
(`mat_records.lineage`), the Revision that made it (`mat_records.lineage_revisions`), and
|
|
16
|
+
what the provenance names: the Source, the Operation, the extraction evidence (document,
|
|
17
|
+
span, model) and the Proposal that brought the change to the branch (merged in that
|
|
18
|
+
Revision, or whose conflict resolution set the value), read in one statement.
|
|
19
|
+
|
|
20
|
+
Only Attributes of the Record's own Kind appear, so a caller whose grants are narrowed to Kinds
|
|
21
|
+
learns nothing about other Kinds here; derived values are not stored content and have no
|
|
22
|
+
history. History and lineage are served at branch heads and, through a snapshot of the
|
|
23
|
+
Revision (`reading.snapshots`), at other Revisions: what the head answered when the Revision
|
|
24
|
+
was the head (the changes along its first-parent chain).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from datetime import UTC, datetime
|
|
32
|
+
from enum import StrEnum
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from pydantic import BaseModel, Field, JsonValue
|
|
36
|
+
from sqlalchemy import (
|
|
37
|
+
LargeBinary,
|
|
38
|
+
Table,
|
|
39
|
+
TextClause,
|
|
40
|
+
and_,
|
|
41
|
+
literal,
|
|
42
|
+
select,
|
|
43
|
+
text,
|
|
44
|
+
tuple_,
|
|
45
|
+
)
|
|
46
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
47
|
+
|
|
48
|
+
from lorebase_core.model import Kind, OriginType
|
|
49
|
+
from lorebase_core.tree import kind_partition
|
|
50
|
+
from lorebase_keeper.reading.cursors import invalid_cursor, issue, position_of
|
|
51
|
+
from lorebase_keeper.reading.points import ReadPoint, not_found
|
|
52
|
+
from lorebase_keeper.reading.problems import tree_unsupported
|
|
53
|
+
from lorebase_keeper.reading.runtime import ReadRuntime
|
|
54
|
+
from lorebase_keeper.reading.sensitivity import Masking
|
|
55
|
+
from lorebase_keeper.reading.snapshots import with_rows
|
|
56
|
+
from lorebase_keeper.reading.views import Served
|
|
57
|
+
from lorebase_store.materialize.rows import CREATED_KEY, ELEMENT_PROVENANCE_KEY
|
|
58
|
+
from lorebase_store.models import metadata
|
|
59
|
+
from lorebase_store.objectstore import PgObjectStore
|
|
60
|
+
from lorebase_store.repositories import ProvenanceRepository
|
|
61
|
+
from lorebase_store.repositories.provenance import ProvenanceRow
|
|
62
|
+
|
|
63
|
+
REVISIONS: Table = metadata.tables["revisions"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Response shapes ------------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ChangeKind(StrEnum):
|
|
70
|
+
CREATED = "created"
|
|
71
|
+
UPDATED = "updated"
|
|
72
|
+
DELETED = "deleted"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ProvenanceOut(BaseModel):
|
|
76
|
+
"""A provenance entry (design 5.11): where a change came from."""
|
|
77
|
+
|
|
78
|
+
id: str
|
|
79
|
+
origin_type: str = Field(
|
|
80
|
+
description="`operation`, `source_sync`, `extraction`, `merge_resolution`, "
|
|
81
|
+
"`schema_migration` or `import`."
|
|
82
|
+
)
|
|
83
|
+
origin: dict[str, JsonValue] = Field(description="The origin document of that type.")
|
|
84
|
+
actor: str
|
|
85
|
+
at: datetime
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AttributeChangeOut(BaseModel):
|
|
89
|
+
name: str = Field(description="Attribute api name.")
|
|
90
|
+
before: JsonValue = Field(default=None, description="Value before; with `values=true`.")
|
|
91
|
+
after: JsonValue = Field(default=None, description="Value after; with `values=true`.")
|
|
92
|
+
provenance: ProvenanceOut | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RecordChangeOut(BaseModel):
|
|
96
|
+
"""One Revision that changed the Record on the branch."""
|
|
97
|
+
|
|
98
|
+
revision: str
|
|
99
|
+
generation: int
|
|
100
|
+
at: datetime
|
|
101
|
+
author: str
|
|
102
|
+
on_behalf_of: str | None = None
|
|
103
|
+
message: str
|
|
104
|
+
origin: dict[str, JsonValue] = Field(description="Origin of the Revision (`type`, `ref`).")
|
|
105
|
+
change: ChangeKind
|
|
106
|
+
attributes: list[AttributeChangeOut] = Field(
|
|
107
|
+
description="Attributes whose value changed, in schema order."
|
|
108
|
+
)
|
|
109
|
+
provenance: ProvenanceOut | None = Field(
|
|
110
|
+
default=None, description="Provenance of the change as a whole (a deletion, for one)."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class RecordHistoryOut(Served):
|
|
115
|
+
branch: str
|
|
116
|
+
kind: str
|
|
117
|
+
key: str
|
|
118
|
+
items: list[RecordChangeOut]
|
|
119
|
+
next_cursor: str | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class LineageProposalOut(BaseModel):
|
|
123
|
+
id: str
|
|
124
|
+
title: str
|
|
125
|
+
status: str
|
|
126
|
+
merged_revision: str | None = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ExtractionEvidenceOut(BaseModel):
|
|
130
|
+
"""Where an extracted value was read: the document span and the model that read it."""
|
|
131
|
+
|
|
132
|
+
job_id: str
|
|
133
|
+
document_id: str
|
|
134
|
+
span_start: int
|
|
135
|
+
span_end: int
|
|
136
|
+
model_id: str
|
|
137
|
+
prompt_version: str
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ValueLineageOut(BaseModel):
|
|
141
|
+
"""Where the current value of one Attribute came from."""
|
|
142
|
+
|
|
143
|
+
name: str = Field(description="Attribute api name.")
|
|
144
|
+
value: JsonValue = None
|
|
145
|
+
provenance: ProvenanceOut | None = None
|
|
146
|
+
revision: str | None = Field(default=None, description="Revision of the last change.")
|
|
147
|
+
revision_at: datetime | None = None
|
|
148
|
+
author: str | None = Field(default=None, description="Author of that Revision.")
|
|
149
|
+
message: str | None = None
|
|
150
|
+
source: str | None = Field(default=None, description="Source api name (`source_sync`).")
|
|
151
|
+
operation: str | None = Field(default=None, description="Operation api name (`operation`).")
|
|
152
|
+
extraction: ExtractionEvidenceOut | None = None
|
|
153
|
+
proposal: LineageProposalOut | None = Field(
|
|
154
|
+
default=None,
|
|
155
|
+
description="The Proposal merged in that Revision, or whose conflict resolution set "
|
|
156
|
+
"the value.",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class RecordLineageOut(Served):
|
|
161
|
+
branch: str
|
|
162
|
+
kind: str
|
|
163
|
+
key: str
|
|
164
|
+
created: RecordChangeOut | None = Field(
|
|
165
|
+
default=None, description="The change that last created the Record."
|
|
166
|
+
)
|
|
167
|
+
changes: int = Field(
|
|
168
|
+
description="Changes of the Record on the branch, at most `read.history.lineage_count_max`."
|
|
169
|
+
)
|
|
170
|
+
changes_truncated: bool = Field(
|
|
171
|
+
default=False,
|
|
172
|
+
description="The Record has more changes than `changes` (the count stopped at "
|
|
173
|
+
"`read.history.lineage_count_max`).",
|
|
174
|
+
)
|
|
175
|
+
attributes: list[ValueLineageOut]
|
|
176
|
+
sources: list[str] = Field(description="Sources behind the current values, by api name.")
|
|
177
|
+
proposals: list[LineageProposalOut] = Field(description="Proposals behind the current values.")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# Loading --------------------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class _Change:
|
|
185
|
+
revision: bytes
|
|
186
|
+
generation: int
|
|
187
|
+
at: datetime
|
|
188
|
+
author: str
|
|
189
|
+
on_behalf_of: str | None
|
|
190
|
+
message: str
|
|
191
|
+
origin: dict[str, Any]
|
|
192
|
+
old_hash: bytes | None
|
|
193
|
+
new_hash: bytes | None
|
|
194
|
+
provenance: Mapping[str, str]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _require_materialized(point: ReadPoint, what: str) -> None:
|
|
198
|
+
if not point.materialized:
|
|
199
|
+
raise tree_unsupported(what)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _changes(
|
|
203
|
+
session: AsyncSession,
|
|
204
|
+
point: ReadPoint,
|
|
205
|
+
kind: Kind,
|
|
206
|
+
key: str,
|
|
207
|
+
*,
|
|
208
|
+
limit: int | None = None,
|
|
209
|
+
before: tuple[int, bytes] | None = None,
|
|
210
|
+
) -> list[_Change]:
|
|
211
|
+
"""Changes of the Record on the branch, newest first (at most `limit`)."""
|
|
212
|
+
h, r = point.layout.history(), REVISIONS
|
|
213
|
+
query = (
|
|
214
|
+
select(
|
|
215
|
+
h.c.revision_id,
|
|
216
|
+
r.c.generation,
|
|
217
|
+
r.c.created_at,
|
|
218
|
+
r.c.author,
|
|
219
|
+
r.c.on_behalf_of,
|
|
220
|
+
r.c.message,
|
|
221
|
+
r.c.origin,
|
|
222
|
+
h.c.old_hash,
|
|
223
|
+
h.c.new_hash,
|
|
224
|
+
h.c.provenance,
|
|
225
|
+
)
|
|
226
|
+
.join(r, and_(r.c.domain_id == h.c.domain_id, r.c.id == h.c.revision_id))
|
|
227
|
+
.where(
|
|
228
|
+
h.c.domain_id == point.domain.id,
|
|
229
|
+
h.c.branch == point.layout.branch,
|
|
230
|
+
h.c.partition == kind_partition(kind.id),
|
|
231
|
+
h.c.element_key == key,
|
|
232
|
+
)
|
|
233
|
+
.order_by(r.c.generation.desc(), h.c.revision_id.desc())
|
|
234
|
+
)
|
|
235
|
+
if before is not None:
|
|
236
|
+
generation, revision = before
|
|
237
|
+
query = query.where(
|
|
238
|
+
tuple_(r.c.generation, h.c.revision_id)
|
|
239
|
+
< tuple_(literal(generation), literal(revision, LargeBinary))
|
|
240
|
+
)
|
|
241
|
+
if limit is not None:
|
|
242
|
+
query = query.limit(limit)
|
|
243
|
+
return [
|
|
244
|
+
_Change(
|
|
245
|
+
bytes(row.revision_id),
|
|
246
|
+
row.generation,
|
|
247
|
+
row.created_at,
|
|
248
|
+
row.author,
|
|
249
|
+
row.on_behalf_of,
|
|
250
|
+
row.message,
|
|
251
|
+
row.origin,
|
|
252
|
+
None if row.old_hash is None else bytes(row.old_hash),
|
|
253
|
+
None if row.new_hash is None else bytes(row.new_hash),
|
|
254
|
+
row.provenance or {},
|
|
255
|
+
)
|
|
256
|
+
for row in await session.execute(query)
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _nothing(_value: Any) -> bool:
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def _provenance(
|
|
265
|
+
session: AsyncSession, point: ReadPoint, runtime: ReadRuntime, ids: Iterable[str]
|
|
266
|
+
) -> dict[str, ProvenanceOut]:
|
|
267
|
+
rows = await ProvenanceRepository(
|
|
268
|
+
session, point.domain.id, writes=runtime.store_settings.writes
|
|
269
|
+
).get_many(ids)
|
|
270
|
+
return {id_: provenance_out(row, point.tells) for id_, row in rows.items()}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def provenance_out(row: ProvenanceRow, tells: Callable[[Any], bool] = _nothing) -> ProvenanceOut:
|
|
274
|
+
"""A provenance entry; its origin document is left out when it names something invisible
|
|
275
|
+
to the caller (`ReadPoint.tells`, an Operation or Source left out of its schema)."""
|
|
276
|
+
origin = dict(row.origin_ref)
|
|
277
|
+
return ProvenanceOut(
|
|
278
|
+
id=row.id,
|
|
279
|
+
origin_type=row.origin_type,
|
|
280
|
+
origin={} if tells(origin) else origin,
|
|
281
|
+
actor=row.actor,
|
|
282
|
+
at=row.at,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def _bodies(
|
|
287
|
+
session: AsyncSession, runtime: ReadRuntime, domain_id: str, hashes: Iterable[bytes | None]
|
|
288
|
+
) -> dict[bytes, Mapping[str, Any]]:
|
|
289
|
+
settings = runtime.store_settings
|
|
290
|
+
store = PgObjectStore(
|
|
291
|
+
session, domain_id, writes=settings.writes, batch=settings.materialize.read_batch_rows
|
|
292
|
+
)
|
|
293
|
+
found = await store.get_many(h for h in hashes if h is not None)
|
|
294
|
+
return {digest: stored.body.get("values") or {} for digest, stored in found.items()}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _change_kind(change: _Change) -> ChangeKind:
|
|
298
|
+
if change.old_hash is None:
|
|
299
|
+
return ChangeKind.CREATED
|
|
300
|
+
if change.new_hash is None:
|
|
301
|
+
return ChangeKind.DELETED
|
|
302
|
+
return ChangeKind.UPDATED
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _change_out(
|
|
306
|
+
kind: Kind,
|
|
307
|
+
change: _Change,
|
|
308
|
+
bodies: Mapping[bytes, Mapping[str, Any]],
|
|
309
|
+
provenance: Mapping[str, ProvenanceOut],
|
|
310
|
+
*,
|
|
311
|
+
values: bool,
|
|
312
|
+
masking: Masking,
|
|
313
|
+
tells: Callable[[Any], bool] = _nothing,
|
|
314
|
+
) -> RecordChangeOut:
|
|
315
|
+
order = _reading_order(kind)
|
|
316
|
+
scope = (kind.id,)
|
|
317
|
+
old = bodies.get(change.old_hash, {}) if change.old_hash is not None else {}
|
|
318
|
+
new = bodies.get(change.new_hash, {}) if change.new_hash is not None else {}
|
|
319
|
+
attributes = []
|
|
320
|
+
for attribute_id, attribute in kind.attributes.items():
|
|
321
|
+
if attribute.is_derived:
|
|
322
|
+
continue
|
|
323
|
+
before, after = old.get(attribute_id), new.get(attribute_id)
|
|
324
|
+
if before == after:
|
|
325
|
+
continue
|
|
326
|
+
provenance_id = change.provenance.get(attribute_id)
|
|
327
|
+
attributes.append(
|
|
328
|
+
AttributeChangeOut(
|
|
329
|
+
name=attribute.api_name,
|
|
330
|
+
before=masking.value(attribute, scope, before) if values else None,
|
|
331
|
+
after=masking.value(attribute, scope, after) if values else None,
|
|
332
|
+
provenance=None if provenance_id is None else provenance.get(provenance_id),
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
element = change.provenance.get(ELEMENT_PROVENANCE_KEY)
|
|
336
|
+
return RecordChangeOut(
|
|
337
|
+
revision=change.revision.hex(),
|
|
338
|
+
generation=change.generation,
|
|
339
|
+
at=change.at,
|
|
340
|
+
author=change.author,
|
|
341
|
+
on_behalf_of=change.on_behalf_of,
|
|
342
|
+
message="" if tells(change.message) else change.message,
|
|
343
|
+
origin={} if tells(change.origin) else change.origin,
|
|
344
|
+
change=_change_kind(change),
|
|
345
|
+
attributes=sorted(attributes, key=lambda a: order(a.name)),
|
|
346
|
+
provenance=None if element is None else provenance.get(element),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _reading_order(kind: Kind) -> Callable[[str], tuple[int, str]]:
|
|
351
|
+
"""Sort key of Attribute api names: key Attributes, the title Attribute, then by name."""
|
|
352
|
+
first = [*kind.primary_key, *([kind.title_attribute] if kind.title_attribute else [])]
|
|
353
|
+
rank: dict[str, int] = {}
|
|
354
|
+
for attribute_id in first:
|
|
355
|
+
if attribute_id in kind.attributes:
|
|
356
|
+
rank.setdefault(kind.attributes[attribute_id].api_name, len(rank))
|
|
357
|
+
return lambda name: (rank.get(name, len(rank)), name)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async def _render(
|
|
361
|
+
session: AsyncSession,
|
|
362
|
+
runtime: ReadRuntime,
|
|
363
|
+
point: ReadPoint,
|
|
364
|
+
kind: Kind,
|
|
365
|
+
changes: Sequence[_Change],
|
|
366
|
+
*,
|
|
367
|
+
values: bool,
|
|
368
|
+
) -> list[RecordChangeOut]:
|
|
369
|
+
domain = point.domain.id
|
|
370
|
+
bodies = await _bodies(
|
|
371
|
+
session, runtime, domain, [h for c in changes for h in (c.old_hash, c.new_hash)]
|
|
372
|
+
)
|
|
373
|
+
provenance = await _provenance(
|
|
374
|
+
session, point, runtime, [p for c in changes for p in c.provenance.values()]
|
|
375
|
+
)
|
|
376
|
+
items = [
|
|
377
|
+
_change_out(
|
|
378
|
+
kind, c, bodies, provenance, values=values, masking=point.masking, tells=point.tells
|
|
379
|
+
)
|
|
380
|
+
for c in changes
|
|
381
|
+
]
|
|
382
|
+
if not point.hidden:
|
|
383
|
+
return items
|
|
384
|
+
# A change of nothing but Attributes left out of the caller's schema (a reference to an
|
|
385
|
+
# invisible Kind) would tell that, and when, something invisible changed.
|
|
386
|
+
return [i for i in items if i.change is not ChangeKind.UPDATED or i.attributes]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# History --------------------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
async def record_history(
|
|
393
|
+
session: AsyncSession,
|
|
394
|
+
runtime: ReadRuntime,
|
|
395
|
+
point: ReadPoint,
|
|
396
|
+
kind: Kind,
|
|
397
|
+
key: str,
|
|
398
|
+
*,
|
|
399
|
+
limit: int,
|
|
400
|
+
cursor: str | None,
|
|
401
|
+
values: bool,
|
|
402
|
+
shape: str,
|
|
403
|
+
) -> RecordHistoryOut:
|
|
404
|
+
"""A page of the changes of `kind/key` on the branch, newest first (see module doc)."""
|
|
405
|
+
position = position_of(cursor, shape, "/cursor")
|
|
406
|
+
before = None
|
|
407
|
+
if position is not None:
|
|
408
|
+
try:
|
|
409
|
+
generation, revision = int(position[0]), bytes.fromhex(str(position[1]))
|
|
410
|
+
except (IndexError, TypeError, ValueError):
|
|
411
|
+
raise invalid_cursor("/cursor") from None
|
|
412
|
+
before = (generation, revision)
|
|
413
|
+
point = await with_rows(session, runtime, point, "Record history")
|
|
414
|
+
changes = await _changes(session, point, kind, key, limit=limit + 1, before=before)
|
|
415
|
+
if not changes and position is None:
|
|
416
|
+
raise not_found(f"Record {kind.api_name}/{key}")
|
|
417
|
+
more = len(changes) > limit
|
|
418
|
+
changes = changes[:limit]
|
|
419
|
+
items = await _render(session, runtime, point, kind, changes, values=values)
|
|
420
|
+
last = changes[-1] if changes else None
|
|
421
|
+
return RecordHistoryOut(
|
|
422
|
+
revision=point.revision_hex,
|
|
423
|
+
served_from=point.served_from,
|
|
424
|
+
branch=point.branch,
|
|
425
|
+
kind=kind.api_name,
|
|
426
|
+
key=key,
|
|
427
|
+
items=items,
|
|
428
|
+
next_cursor=issue(shape, [last.generation, last.revision.hex()])
|
|
429
|
+
if more and last is not None
|
|
430
|
+
else None,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# Lineage --------------------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
# Per Attribute of the lineage, its provenance entry and the Revision of its last change, at the
|
|
437
|
+
# branch head: from `mat_records.lineage_revisions`. For rows that do not name them (written
|
|
438
|
+
# before they were kept and not yet backfilled), the newest `:scan` changes of the Record are
|
|
439
|
+
# read once (`recent`, skipped when the row names everything) and searched: the newest change
|
|
440
|
+
# that recorded that entry for the Attribute, and the newest creation. The change that created
|
|
441
|
+
# the Record is the history row of `$created`, or that fallback when the row is missing. JSON
|
|
442
|
+
# that is not an object (null lineage, for one) reads as empty. Changes are counted up to
|
|
443
|
+
# `:count`, so a Record with a long history costs no more than that bound. Shared lineage maps
|
|
444
|
+
# (revision 0036) are resolved with `lb_lineage`.
|
|
445
|
+
_HEAD = """
|
|
446
|
+
rec AS MATERIALIZED (
|
|
447
|
+
SELECT m."values" AS vals,
|
|
448
|
+
CASE WHEN jsonb_typeof(m.lineage) = 'object' THEN lb_lineage(m.domain_id, m.lineage)
|
|
449
|
+
ELSE '{{}}'::jsonb END AS lineage,
|
|
450
|
+
CASE WHEN jsonb_typeof(m.lineage_revisions) = 'object'
|
|
451
|
+
THEN lb_lineage(m.domain_id, m.lineage_revisions) END AS revs
|
|
452
|
+
FROM {records}
|
|
453
|
+
WHERE m.domain_id = :domain AND m.branch = :branch AND m.kind_id = :kind AND m.key = :key
|
|
454
|
+
),
|
|
455
|
+
created_direct AS MATERIALIZED (
|
|
456
|
+
SELECT h.revision_id, h.old_hash, h.new_hash, h.provenance
|
|
457
|
+
FROM rec
|
|
458
|
+
JOIN {history_h}
|
|
459
|
+
ON h.domain_id = :domain AND h.branch = :branch AND h.partition = :partition
|
|
460
|
+
AND h.element_key = :key AND h.revision_id = decode(rec.revs ->> '{created_key}', 'hex')
|
|
461
|
+
LIMIT 1
|
|
462
|
+
),
|
|
463
|
+
recent AS MATERIALIZED (
|
|
464
|
+
SELECT h.revision_id, h.old_hash, h.new_hash, h.provenance, r.generation
|
|
465
|
+
FROM {history_h}
|
|
466
|
+
JOIN revisions r ON r.domain_id = h.domain_id AND r.id = h.revision_id
|
|
467
|
+
WHERE h.domain_id = :domain AND h.branch = :branch AND h.partition = :partition
|
|
468
|
+
AND h.element_key = :key
|
|
469
|
+
AND EXISTS (SELECT 1 FROM rec)
|
|
470
|
+
AND (NOT EXISTS (SELECT 1 FROM created_direct)
|
|
471
|
+
OR EXISTS (
|
|
472
|
+
SELECT 1 FROM rec CROSS JOIN LATERAL jsonb_object_keys(rec.lineage) AS k(attribute)
|
|
473
|
+
WHERE (rec.revs ->> k.attribute) IS NULL))
|
|
474
|
+
ORDER BY r.generation DESC, h.revision_id DESC
|
|
475
|
+
LIMIT :scan
|
|
476
|
+
),
|
|
477
|
+
lin AS (
|
|
478
|
+
SELECT l.attribute, l.provenance_id,
|
|
479
|
+
COALESCE(
|
|
480
|
+
decode(rec.revs ->> l.attribute, 'hex'),
|
|
481
|
+
(SELECT s.revision_id FROM recent s
|
|
482
|
+
WHERE s.provenance ->> l.attribute = l.provenance_id
|
|
483
|
+
ORDER BY s.generation DESC, s.revision_id DESC
|
|
484
|
+
LIMIT 1)) AS revision_id
|
|
485
|
+
FROM rec
|
|
486
|
+
CROSS JOIN LATERAL jsonb_each_text(rec.lineage) AS l(attribute, provenance_id)
|
|
487
|
+
),
|
|
488
|
+
created AS (
|
|
489
|
+
SELECT revision_id, old_hash, new_hash, provenance FROM created_direct
|
|
490
|
+
UNION ALL
|
|
491
|
+
SELECT c.revision_id, c.old_hash, c.new_hash, c.provenance
|
|
492
|
+
FROM (
|
|
493
|
+
SELECT s.revision_id, s.old_hash, s.new_hash, s.provenance
|
|
494
|
+
FROM recent s
|
|
495
|
+
WHERE s.old_hash IS NULL AND NOT EXISTS (SELECT 1 FROM created_direct)
|
|
496
|
+
ORDER BY s.generation DESC, s.revision_id DESC
|
|
497
|
+
LIMIT 1
|
|
498
|
+
) AS c
|
|
499
|
+
),
|
|
500
|
+
counted AS (
|
|
501
|
+
SELECT count(*) AS changes
|
|
502
|
+
FROM (
|
|
503
|
+
SELECT 1
|
|
504
|
+
FROM {history_h}
|
|
505
|
+
WHERE h.domain_id = :domain AND h.branch = :branch AND h.partition = :partition
|
|
506
|
+
AND h.element_key = :key
|
|
507
|
+
LIMIT :count
|
|
508
|
+
) AS c
|
|
509
|
+
)
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
# What the lineage names, in one row: the Revisions, provenance entries and Proposals behind
|
|
513
|
+
# the values, the change that created the Record (with the values it wrote) and the count of
|
|
514
|
+
# changes.
|
|
515
|
+
_TAIL = """
|
|
516
|
+
SELECT
|
|
517
|
+
{found} AS found,
|
|
518
|
+
{values} AS vals,
|
|
519
|
+
(SELECT jsonb_agg(jsonb_build_object(
|
|
520
|
+
'attribute', attribute, 'provenance', provenance_id,
|
|
521
|
+
'revision', encode(revision_id, 'hex')))
|
|
522
|
+
FROM lin) AS lineage,
|
|
523
|
+
(SELECT jsonb_agg(jsonb_build_object(
|
|
524
|
+
'id', encode(r.id, 'hex'), 'generation', r.generation, 'at', r.created_at,
|
|
525
|
+
'author', r.author, 'on_behalf_of', r.on_behalf_of, 'message', r.message,
|
|
526
|
+
'origin', r.origin))
|
|
527
|
+
FROM revisions r
|
|
528
|
+
WHERE r.domain_id = :domain
|
|
529
|
+
AND r.id IN (SELECT revision_id FROM lin UNION SELECT revision_id FROM created)
|
|
530
|
+
) AS revisions,
|
|
531
|
+
(SELECT jsonb_agg(jsonb_build_object(
|
|
532
|
+
'id', p.id, 'origin_type', p.origin_type, 'origin', p.origin_ref, 'actor', p.actor,
|
|
533
|
+
'at', p.at))
|
|
534
|
+
FROM provenance p
|
|
535
|
+
WHERE p.domain_id = :domain
|
|
536
|
+
AND p.id IN (
|
|
537
|
+
SELECT provenance_id FROM lin
|
|
538
|
+
UNION SELECT e.value FROM created,
|
|
539
|
+
jsonb_each_text({provenance_object}) AS e)
|
|
540
|
+
) AS provenance,
|
|
541
|
+
(SELECT jsonb_agg(jsonb_build_object(
|
|
542
|
+
'id', q.id, 'title', q.title, 'status', q.status,
|
|
543
|
+
'merged_revision', encode(q.merged_revision, 'hex')) ORDER BY q.created_at, q.id)
|
|
544
|
+
FROM proposals q
|
|
545
|
+
WHERE q.domain_id = :domain
|
|
546
|
+
AND (q.merged_revision IN (SELECT revision_id FROM lin)
|
|
547
|
+
OR q.id IN (
|
|
548
|
+
SELECT p.origin_ref ->> 'proposal_id' FROM provenance p
|
|
549
|
+
WHERE p.domain_id = :domain AND p.origin_type = '{merge_resolution}'
|
|
550
|
+
AND p.id IN (SELECT provenance_id FROM lin)))
|
|
551
|
+
) AS proposals,
|
|
552
|
+
(SELECT jsonb_build_object(
|
|
553
|
+
'revision', encode(c.revision_id, 'hex'), 'new_hash', encode(c.new_hash, 'hex'),
|
|
554
|
+
'provenance', {created_provenance}, 'values', o.body -> 'values')
|
|
555
|
+
FROM created c
|
|
556
|
+
LEFT JOIN objects o ON o.domain_id = :domain AND o.hash = c.new_hash
|
|
557
|
+
LIMIT 1) AS created,
|
|
558
|
+
(SELECT changes FROM counted) AS changes
|
|
559
|
+
"""
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _object(expression: str) -> str:
|
|
563
|
+
"""`expression` when it is a JSON object, else an empty one (null and other JSON)."""
|
|
564
|
+
return (
|
|
565
|
+
f"(CASE WHEN jsonb_typeof({expression}) = 'object' THEN {expression} "
|
|
566
|
+
"ELSE '{}'::jsonb END)"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _lineage_statement(point: ReadPoint) -> TextClause:
|
|
571
|
+
layout = point.layout
|
|
572
|
+
ctes = _HEAD.format(
|
|
573
|
+
records=layout.records_from("m"),
|
|
574
|
+
history_h=layout.history_from("h"),
|
|
575
|
+
created_key=CREATED_KEY,
|
|
576
|
+
)
|
|
577
|
+
tail = _TAIL.format(
|
|
578
|
+
found="EXISTS (SELECT 1 FROM rec)",
|
|
579
|
+
values="(SELECT vals FROM rec)",
|
|
580
|
+
merge_resolution=OriginType.MERGE_RESOLUTION.value,
|
|
581
|
+
provenance_object=_object("created.provenance"),
|
|
582
|
+
created_provenance=_object("c.provenance"),
|
|
583
|
+
)
|
|
584
|
+
return text(f"WITH {ctes.strip()}{tail}")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _utc(value: str) -> datetime:
|
|
588
|
+
return datetime.fromisoformat(value).astimezone(UTC)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
async def record_lineage(
|
|
592
|
+
session: AsyncSession,
|
|
593
|
+
runtime: ReadRuntime,
|
|
594
|
+
point: ReadPoint,
|
|
595
|
+
kind: Kind,
|
|
596
|
+
key: str,
|
|
597
|
+
) -> RecordLineageOut:
|
|
598
|
+
"""Where the values of `kind/key` at the read point came from (see module doc): one
|
|
599
|
+
statement over the head's rows, or a snapshot's at another Revision."""
|
|
600
|
+
point = await with_rows(session, runtime, point, "Record lineage")
|
|
601
|
+
settings = runtime.read.history
|
|
602
|
+
params = {
|
|
603
|
+
"domain": point.domain.id,
|
|
604
|
+
"branch": point.layout.branch,
|
|
605
|
+
"kind": kind.id,
|
|
606
|
+
"partition": kind_partition(kind.id),
|
|
607
|
+
"key": key,
|
|
608
|
+
"scan": settings.lineage_scan_changes,
|
|
609
|
+
"count": settings.lineage_count_max + 1,
|
|
610
|
+
}
|
|
611
|
+
row = (await session.execute(_lineage_statement(point), params)).mappings().one()
|
|
612
|
+
if not row["found"]:
|
|
613
|
+
raise not_found(f"Record {kind.api_name}/{key}")
|
|
614
|
+
values: Mapping[str, Any] = row["vals"] or {}
|
|
615
|
+
lineage = {item["attribute"]: item for item in row["lineage"] or ()}
|
|
616
|
+
revisions = {item["id"]: item for item in row["revisions"] or ()}
|
|
617
|
+
provenance = {
|
|
618
|
+
item["id"]: ProvenanceOut(
|
|
619
|
+
id=item["id"],
|
|
620
|
+
origin_type=item["origin_type"],
|
|
621
|
+
origin={} if point.tells(item["origin"]) else item["origin"],
|
|
622
|
+
actor=item["actor"],
|
|
623
|
+
at=_utc(item["at"]),
|
|
624
|
+
)
|
|
625
|
+
for item in row["provenance"] or ()
|
|
626
|
+
}
|
|
627
|
+
by_revision: dict[str, LineageProposalOut] = {}
|
|
628
|
+
by_id: dict[str, LineageProposalOut] = {}
|
|
629
|
+
for item in row["proposals"] or ():
|
|
630
|
+
out = LineageProposalOut.model_validate(item)
|
|
631
|
+
if point.tells(out.title):
|
|
632
|
+
out.title = ""
|
|
633
|
+
by_id[out.id] = out
|
|
634
|
+
if out.merged_revision is not None:
|
|
635
|
+
by_revision.setdefault(out.merged_revision, out)
|
|
636
|
+
sources = {source.id: source.api_name for source in point.schema.sources.values()}
|
|
637
|
+
|
|
638
|
+
attributes: list[ValueLineageOut] = []
|
|
639
|
+
for attribute_id, attribute in kind.attributes.items():
|
|
640
|
+
if attribute.is_derived or (attribute_id not in values and attribute_id not in lineage):
|
|
641
|
+
continue
|
|
642
|
+
held = lineage.get(attribute_id)
|
|
643
|
+
entry = None if held is None else provenance.get(held["provenance"])
|
|
644
|
+
setter = None if held is None else revisions.get(held["revision"])
|
|
645
|
+
origin: Mapping[str, Any] = {} if entry is None else entry.origin
|
|
646
|
+
origin_type = None if entry is None else entry.origin_type
|
|
647
|
+
proposal = None
|
|
648
|
+
if origin_type == OriginType.MERGE_RESOLUTION:
|
|
649
|
+
proposal = by_id.get(str(origin.get("proposal_id")))
|
|
650
|
+
if proposal is None and setter is not None:
|
|
651
|
+
proposal = by_revision.get(setter["id"])
|
|
652
|
+
source = operation = None
|
|
653
|
+
extraction = None
|
|
654
|
+
if origin_type == OriginType.SOURCE_SYNC:
|
|
655
|
+
source = sources.get(str(origin.get("source_id")))
|
|
656
|
+
elif origin_type == OriginType.OPERATION and origin.get("operation"):
|
|
657
|
+
operation = str(origin["operation"])
|
|
658
|
+
elif origin_type == OriginType.EXTRACTION:
|
|
659
|
+
extraction = ExtractionEvidenceOut.model_validate(origin)
|
|
660
|
+
attributes.append(
|
|
661
|
+
ValueLineageOut(
|
|
662
|
+
name=attribute.api_name,
|
|
663
|
+
value=point.masking.value(attribute, (kind.id,), values.get(attribute_id)),
|
|
664
|
+
provenance=entry,
|
|
665
|
+
revision=None if setter is None else setter["id"],
|
|
666
|
+
revision_at=None if setter is None else _utc(setter["at"]),
|
|
667
|
+
author=None if setter is None else setter["author"],
|
|
668
|
+
message=None
|
|
669
|
+
if setter is None or point.tells(setter["message"])
|
|
670
|
+
else setter["message"],
|
|
671
|
+
source=source,
|
|
672
|
+
operation=operation,
|
|
673
|
+
extraction=extraction,
|
|
674
|
+
proposal=proposal,
|
|
675
|
+
)
|
|
676
|
+
)
|
|
677
|
+
order = _reading_order(kind)
|
|
678
|
+
attributes.sort(key=lambda a: order(a.name))
|
|
679
|
+
proposals = {p.id: p for a in attributes if (p := a.proposal) is not None}
|
|
680
|
+
changes = int(row["changes"] or 0)
|
|
681
|
+
return RecordLineageOut(
|
|
682
|
+
revision=point.revision_hex,
|
|
683
|
+
served_from=point.served_from,
|
|
684
|
+
branch=point.branch,
|
|
685
|
+
kind=kind.api_name,
|
|
686
|
+
key=key,
|
|
687
|
+
created=_created(kind, point, row["created"], revisions, provenance),
|
|
688
|
+
changes=min(changes, settings.lineage_count_max),
|
|
689
|
+
changes_truncated=changes > settings.lineage_count_max,
|
|
690
|
+
attributes=attributes,
|
|
691
|
+
sources=sorted({a.source for a in attributes if a.source is not None}),
|
|
692
|
+
proposals=list(proposals.values()),
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _created(
|
|
697
|
+
kind: Kind,
|
|
698
|
+
point: ReadPoint,
|
|
699
|
+
created: Mapping[str, Any] | None,
|
|
700
|
+
revisions: Mapping[str, Mapping[str, Any]],
|
|
701
|
+
provenance: Mapping[str, ProvenanceOut],
|
|
702
|
+
) -> RecordChangeOut | None:
|
|
703
|
+
"""The change that last created the Record, rendered like a history entry."""
|
|
704
|
+
if created is None or (revision := revisions.get(created["revision"])) is None:
|
|
705
|
+
return None
|
|
706
|
+
new_hash = bytes.fromhex(created["new_hash"])
|
|
707
|
+
change = _Change(
|
|
708
|
+
bytes.fromhex(created["revision"]),
|
|
709
|
+
int(revision["generation"]),
|
|
710
|
+
_utc(revision["at"]),
|
|
711
|
+
revision["author"],
|
|
712
|
+
revision["on_behalf_of"],
|
|
713
|
+
revision["message"],
|
|
714
|
+
revision["origin"],
|
|
715
|
+
None,
|
|
716
|
+
new_hash,
|
|
717
|
+
created["provenance"] or {},
|
|
718
|
+
)
|
|
719
|
+
bodies = {new_hash: created["values"] or {}}
|
|
720
|
+
return _change_out(
|
|
721
|
+
kind, change, bodies, provenance, values=False, masking=point.masking, tells=point.tells
|
|
722
|
+
)
|