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,268 @@
1
+ """Traversal and neighborhoods (design 8.3).
2
+
3
+ Traversal runs one set-based query per hop, capped per hop. A neighborhood is one statement
4
+ of the store primitive `lorebase_store.traverse.neighborhood` (a recursive walk with fan-out,
5
+ Link and node caps; hop by hop when it follows a path Derivation composed at read time). Both
6
+ read the materialized head, or at another Revision a snapshot of it (`reading.snapshots`).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from typing import Any
13
+
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ from lorebase_core.model import Kind, LinkDirection
17
+ from lorebase_core.query import (
18
+ Condition,
19
+ Hop,
20
+ QueryError,
21
+ Target,
22
+ condition_kinds,
23
+ resolve_hop,
24
+ resolve_where,
25
+ )
26
+ from lorebase_keeper.reading.points import ReadPoint, not_found
27
+ from lorebase_keeper.reading.problems import invalid, query_problem
28
+ from lorebase_keeper.reading.records import compiler_for, get_record, require_kind
29
+ from lorebase_keeper.reading.requests import NeighborhoodRequest, TraverseRequest
30
+ from lorebase_keeper.reading.runtime import ReadRuntime
31
+ from lorebase_keeper.reading.sensitivity import condition_fields, require_comparable
32
+ from lorebase_keeper.reading.snapshots import with_rows
33
+ from lorebase_keeper.reading.views import (
34
+ Edge,
35
+ EdgeRef,
36
+ NeighborhoodResponse,
37
+ RecordOut,
38
+ RecordRef,
39
+ TraverseHopOut,
40
+ TraverseResponse,
41
+ link_values,
42
+ record_out,
43
+ record_ref,
44
+ served,
45
+ )
46
+ from lorebase_store.reads import Direction, hydrate_records
47
+ from lorebase_store.repositories.materialized import RecordRow
48
+ from lorebase_store.traverse import neighborhood as walk_neighborhood
49
+
50
+
51
+ def _edge(point: ReadPoint, hop: Hop, row: Mapping[Any, Any]) -> Edge:
52
+ return Edge(
53
+ relation=hop.name,
54
+ source=record_ref(point.schema, hop.near_kind, row["near_key"]),
55
+ to=record_ref(
56
+ point.schema, hop.far.kind_ids[0], row["far_key"], row["title"], point.masking
57
+ ),
58
+ values=link_values(point.schema, hop.relation, row["link_values"], point.masking),
59
+ )
60
+
61
+
62
+ async def traverse(
63
+ session: AsyncSession, runtime: ReadRuntime, point: ReadPoint, request: TraverseRequest
64
+ ) -> TraverseResponse:
65
+ """Follow `request.path` from the start Record; each hop keeps at most its limit. The
66
+ whole path is validated and authorized before any row is read."""
67
+ caps = runtime.read.traverse
68
+ if len(request.path) > caps.max_hops:
69
+ raise invalid("/body/path", f"a path has at most {caps.max_hops} steps", "limit_exceeded")
70
+ kind = require_kind(point, request.start.kind)
71
+ current = Target(kind.api_name, (kind,))
72
+ steps: list[tuple[Hop, int, Condition | None]] = []
73
+ for index, step in enumerate(request.path):
74
+ pointer = f"/body/path/{index}"
75
+ hop = resolve_hop(point.schema, current, step.relation)
76
+ if isinstance(hop, str):
77
+ raise invalid(f"{pointer}/relation", hop, "unknown_relation")
78
+ point.scope.require(hop.kinds_read)
79
+ limit = step.limit or caps.max_nodes_per_hop
80
+ if limit > caps.max_nodes_per_hop:
81
+ raise invalid(
82
+ f"{pointer}/limit", f"must not exceed {caps.max_nodes_per_hop}", "limit_exceeded"
83
+ )
84
+ try:
85
+ where = (
86
+ None
87
+ if step.where is None
88
+ else resolve_where(
89
+ step.where, hop.far, point.schema, runtime.read.query_limits(), "/where"
90
+ )
91
+ )
92
+ except QueryError as error:
93
+ raise query_problem(error, pointer) from None
94
+ point.scope.require(condition_kinds(where))
95
+ require_comparable(
96
+ point.schema, point.masking, condition_fields(where, hop.far.kinds), f"{pointer}/where"
97
+ )
98
+ steps.append((hop, limit, where))
99
+ current = hop.far
100
+ point = await with_rows(session, runtime, point, "Traversal")
101
+ start, _ = await get_record(
102
+ session, runtime, point, kind, request.start.key, links=False, lineage=False
103
+ )
104
+ compiler = compiler_for(point, runtime)
105
+ frontier = [request.start.key]
106
+ hops: list[TraverseHopOut] = []
107
+ for hop, limit, where in steps:
108
+ rows: list[Mapping[Any, Any]] = []
109
+ if frontier:
110
+ query = compiler.neighbors(hop, frontier, where=where, limit=limit + 1)
111
+ rows = list((await session.execute(query)).mappings())
112
+ truncated = len(rows) > limit
113
+ rows = rows[:limit]
114
+ reached: dict[str, RecordOut] = {}
115
+ for row in rows:
116
+ reached.setdefault(row["far_key"], record_out(point.schema, row, masking=point.masking))
117
+ hops.append(
118
+ TraverseHopOut(
119
+ relation=hop.name,
120
+ records=list(reached.values()),
121
+ edges=[_edge(point, hop, row) for row in rows],
122
+ truncated=truncated,
123
+ )
124
+ )
125
+ frontier = list(reached)
126
+ return TraverseResponse(**served(point), start=start, hops=hops)
127
+
128
+
129
+ async def neighborhood(
130
+ session: AsyncSession, runtime: ReadRuntime, point: ReadPoint, request: NeighborhoodRequest
131
+ ) -> NeighborhoodResponse:
132
+ """Records and Links around the seeds, breadth first, within the depth and the caps.
133
+
134
+ The store primitive `lorebase_store.traverse.neighborhood`; caps are the smaller of
135
+ `read.traverse` and `store.traverse`. Nodes come nearest first; each Link is reported
136
+ once, by its forward name, from its `from` Record to its `to` Record. Path Derivations are
137
+ followed when named in `relations`. With `values: false` nodes are Record headers and
138
+ edges Link endpoints: the walk is the same, the response carries no values.
139
+ """
140
+ caps, store = runtime.read.traverse, runtime.store_settings.traverse
141
+ max_seeds = min(caps.max_seeds, store.max_seeds)
142
+ if len(request.seeds) > max_seeds:
143
+ raise invalid("/body/seeds", f"at most {max_seeds} seeds", "limit_exceeded")
144
+ max_depth = min(caps.max_depth, store.max_depth)
145
+ if request.depth > max_depth:
146
+ raise invalid("/body/depth", f"must not exceed {max_depth}", "limit_exceeded")
147
+ nodes_cap = min(caps.max_nodes, store.max_nodes)
148
+ edges_cap = min(caps.max_edges, store.max_links)
149
+ max_nodes = request.max_nodes or nodes_cap
150
+ max_edges = request.max_edges or edges_cap
151
+ if max_nodes > nodes_cap:
152
+ raise invalid("/body/max_nodes", f"must not exceed {nodes_cap}", "limit_exceeded")
153
+ if max_edges > edges_cap:
154
+ raise invalid("/body/max_edges", f"must not exceed {edges_cap}", "limit_exceeded")
155
+ follow = _follow(point, _followed(point, request.relations))
156
+ seeds = [(require_kind(point, seed.kind).id, seed.key) for seed in request.seeds]
157
+ point = await with_rows(session, runtime, point, "Neighborhoods")
158
+ found = await hydrate_records(
159
+ session, point.domain.id, point.layout, seeds, runtime=runtime.store
160
+ )
161
+ for kind_id, key in seeds:
162
+ if (kind_id, key) not in found:
163
+ raise not_found(f"Record {point.schema.kinds[kind_id].api_name}/{key}")
164
+
165
+ walked = await walk_neighborhood(
166
+ session,
167
+ point.domain.id,
168
+ point.layout,
169
+ point.schema,
170
+ seeds,
171
+ depth=request.depth,
172
+ follow=follow,
173
+ max_nodes=max_nodes,
174
+ max_links=max_edges,
175
+ runtime=runtime.store,
176
+ )
177
+ titles = {(n.record.kind_id, n.record.key): n.record.title for n in walked.records}
178
+ nodes: list[RecordOut | RecordRef] = [
179
+ record_out(point.schema, _row(n.record), masking=point.masking)
180
+ if request.values
181
+ else record_ref(point.schema, n.record.kind_id, n.record.key, n.record.title, point.masking)
182
+ for n in walked.records
183
+ ]
184
+ edges: list[Edge | EdgeRef] = []
185
+ for link in walked.links:
186
+ relation = point.schema.link_relation(link.relation_id)
187
+ assert relation is not None # the walk follows only Relations of the schema
188
+ source = record_ref(point.schema, relation.from_kind, link.from_key)
189
+ to = record_ref(
190
+ point.schema,
191
+ relation.to_kind,
192
+ link.to_key,
193
+ titles.get((relation.to_kind, link.to_key)),
194
+ point.masking,
195
+ )
196
+ if not request.values:
197
+ edges.append(EdgeRef(relation=relation.api_name, source=source, to=to))
198
+ continue
199
+ edges.append(
200
+ Edge(
201
+ relation=relation.api_name,
202
+ source=source,
203
+ to=to,
204
+ values=link_values(point.schema, relation, link.values, point.masking),
205
+ )
206
+ )
207
+ return NeighborhoodResponse(
208
+ **served(point), nodes=nodes, edges=edges, truncated=walked.truncated
209
+ )
210
+
211
+
212
+ def _row(record: RecordRow) -> dict[str, Any]:
213
+ """A store Record row as the mapping `record_out` reads."""
214
+ return {
215
+ "kind_id": record.kind_id,
216
+ "key": record.key,
217
+ "title": record.title,
218
+ "values": record.values,
219
+ "updated_revision": record.updated_revision,
220
+ "updated_at": record.updated_at,
221
+ }
222
+
223
+
224
+ def _followed(point: ReadPoint, names: list[str] | None) -> set[tuple[str, LinkDirection]] | None:
225
+ if names is None:
226
+ return None
227
+ wanted: set[tuple[str, LinkDirection]] = set()
228
+ for index, name in enumerate(names):
229
+ found = point.schema.link_by_api_name(name)
230
+ if found is None:
231
+ raise invalid(f"/body/relations/{index}", f"unknown Relation {name!r}", "unknown")
232
+ relation, direction = found
233
+ wanted.add((relation.id, direction))
234
+ return wanted
235
+
236
+
237
+ def _follow(
238
+ point: ReadPoint, wanted: set[tuple[str, LinkDirection]] | None
239
+ ) -> dict[str, Direction]:
240
+ """Relations to walk (id to direction): those requested (every Relation by default, path
241
+ Derivations only when named) in the directions whose far Kind the caller may read; Kinds
242
+ the caller may not read are left out, and so are paths crossing one."""
243
+ schema = point.schema
244
+ paths = schema.path_relations()
245
+ relations = [
246
+ *schema.relations.values(),
247
+ *(path.relation for path in paths.values() if wanted is not None),
248
+ ]
249
+ follow: dict[str, Direction] = {}
250
+ for relation in sorted(relations, key=lambda r: r.id):
251
+ path = paths.get(relation.id)
252
+ inner = () if path is None else path.inner_kinds
253
+ ways: list[Direction] = []
254
+ for direction in LinkDirection:
255
+ if wanted is not None and (relation.id, direction) not in wanted:
256
+ continue
257
+ far: Kind | None = schema.kinds.get(relation.far_endpoint(direction))
258
+ if far is None or not point.scope.allows(far.api_name):
259
+ continue
260
+ if not all(
261
+ (k := schema.kinds.get(kind_id)) is not None and point.scope.allows(k.api_name)
262
+ for kind_id in inner
263
+ ):
264
+ continue
265
+ ways.append(Direction.OUT if direction is LinkDirection.FORWARD else Direction.IN)
266
+ if ways:
267
+ follow[relation.id] = Direction.BOTH if len(ways) == 2 else ways[0]
268
+ return follow
@@ -0,0 +1,152 @@
1
+ """Revisions, the log of a branch and per-partition counts (design 6.3, 6.4, 11.1).
2
+
3
+ The log walks the first-parent chain of the branch head (`all=true`: every ancestor) with a
4
+ recursive query, newest first by generation; pages continue with a `(generation, id)` keyset.
5
+ The walk covers the whole history on every page, which is fine for M1 history sizes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import defaultdict
11
+ from datetime import datetime
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel
15
+ from sqlalchemy import LargeBinary, Table, cast, literal, or_, select, tuple_
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from lorebase_core.model import Schema
19
+ from lorebase_keeper.reading.compiler import json_text
20
+ from lorebase_keeper.reading.diff import partition_name
21
+ from lorebase_keeper.reading.runtime import ReadRuntime
22
+ from lorebase_store.materialize.rows import PartitionType
23
+ from lorebase_store.models import metadata
24
+ from lorebase_store.nodestore import NodeRepository
25
+ from lorebase_store.repositories import RevisionRow
26
+
27
+ REVISIONS: Table = metadata.tables["revisions"]
28
+ REVISION_PARENTS: Table = metadata.tables["revision_parents"]
29
+
30
+
31
+ class RevisionOut(BaseModel):
32
+ id: str
33
+ parents: list[str]
34
+ root: str
35
+ generation: int
36
+ author: str
37
+ on_behalf_of: str | None = None
38
+ message: str
39
+ origin: dict[str, Any]
40
+ stats: dict[str, Any]
41
+ created_at: datetime
42
+
43
+
44
+ class PartitionCount(BaseModel):
45
+ partition: str
46
+ element: PartitionType
47
+ name: str
48
+ count: int
49
+
50
+
51
+ def revision_out(row: RevisionRow) -> RevisionOut:
52
+ return RevisionOut(
53
+ id=row.id.hex(),
54
+ parents=[p.hex() for p in row.parents],
55
+ root=row.root.hex(),
56
+ generation=row.generation,
57
+ author=row.author,
58
+ on_behalf_of=row.on_behalf_of,
59
+ message=row.message,
60
+ origin=row.origin,
61
+ stats=row.stats,
62
+ created_at=row.created_at,
63
+ )
64
+
65
+
66
+ async def log(
67
+ session: AsyncSession,
68
+ domain_id: str,
69
+ head: bytes,
70
+ *,
71
+ first_parent: bool,
72
+ origin_type: str | None,
73
+ author: str | None,
74
+ limit: int,
75
+ after: tuple[int, bytes] | None,
76
+ ) -> tuple[list[RevisionOut], bool]:
77
+ """A page of the history of `head`, newest first; the flag tells whether more follow."""
78
+ rp = REVISION_PARENTS
79
+ chain = select(cast(literal(head, LargeBinary), LargeBinary).label("id")).cte(
80
+ "chain", recursive=True
81
+ )
82
+ step = select(rp.c.parent_id).where(rp.c.domain_id == domain_id, rp.c.revision_id == chain.c.id)
83
+ if first_parent:
84
+ step = step.where(rp.c.position == 0)
85
+ chain = chain.union(step)
86
+ r = REVISIONS
87
+ query = (
88
+ select(
89
+ r.c.id,
90
+ r.c.root,
91
+ r.c.generation,
92
+ r.c.author,
93
+ r.c.on_behalf_of,
94
+ r.c.message,
95
+ r.c.origin,
96
+ r.c.stats,
97
+ r.c.created_at,
98
+ )
99
+ .join(chain, r.c.id == chain.c.id)
100
+ .where(r.c.domain_id == domain_id)
101
+ .order_by(r.c.generation.desc(), r.c.id.desc())
102
+ .limit(limit + 1)
103
+ )
104
+ if origin_type is not None:
105
+ query = query.where(json_text(r.c.origin, "type") == origin_type)
106
+ if author is not None:
107
+ query = query.where(or_(r.c.author == author, r.c.on_behalf_of == author))
108
+ if after is not None:
109
+ generation, last = after
110
+ query = query.where(
111
+ tuple_(r.c.generation, r.c.id) < tuple_(literal(generation), literal(last, LargeBinary))
112
+ )
113
+ rows = list((await session.execute(query)).mappings())
114
+ more = len(rows) > limit
115
+ rows = rows[:limit]
116
+ parents: dict[bytes, list[bytes]] = defaultdict(list)
117
+ if rows:
118
+ result = await session.execute(
119
+ select(rp.c.revision_id, rp.c.parent_id)
120
+ .where(rp.c.domain_id == domain_id, rp.c.revision_id.in_([row["id"] for row in rows]))
121
+ .order_by(rp.c.revision_id, rp.c.position)
122
+ )
123
+ for revision, parent in result:
124
+ parents[revision].append(parent)
125
+ items = [revision_out(RevisionRow(parents=tuple(parents[row["id"]]), **row)) for row in rows]
126
+ return items, more
127
+
128
+
129
+ async def partition_counts(
130
+ session: AsyncSession,
131
+ runtime: ReadRuntime,
132
+ domain_id: str,
133
+ revision: RevisionRow,
134
+ schema: Schema,
135
+ ) -> list[PartitionCount]:
136
+ """Entries per partition at a Revision, read from the tree root in O(1)."""
137
+ settings = runtime.store_settings
138
+ nodes = NodeRepository(
139
+ session,
140
+ domain_id,
141
+ writes=settings.writes,
142
+ cache=runtime.store.node_cache,
143
+ batch=settings.nodes.prefetch_batch,
144
+ )
145
+ tree = await nodes.load_tree(revision.root)
146
+ counts = []
147
+ for partition, ref in sorted(tree.partitions.items()):
148
+ element, name = partition_name(partition, [schema])
149
+ counts.append(
150
+ PartitionCount(partition=partition, element=element, name=name, count=ref.count)
151
+ )
152
+ return counts