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.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. 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
+ )