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,780 @@
1
+ """Record and Link reads: get, list and query, Links of a Record or a Relation.
2
+
3
+ Reads at the branch head use the materialized rows and the query compiler; reads at another
4
+ Revision go through `TreeReader` for key lookups, listings of a Kind or a Trait ordered by key,
5
+ and Links (path Derivations composed from the Links of their steps there), and through a
6
+ snapshot of the Revision (`reading.snapshots`) for everything else.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import defaultdict
12
+ from collections.abc import Mapping, Sequence
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from sqlalchemy import Select, and_, func, literal, literal_column, select
17
+ from sqlalchemy.ext.asyncio import AsyncSession
18
+
19
+ from lorebase_core.model import Kind, LinkDirection, PathRelation, Relation
20
+ from lorebase_core.model.paths import walk
21
+ from lorebase_core.model.relation import RelationBacking
22
+ from lorebase_core.query import (
23
+ KEY_FIELD,
24
+ Hop,
25
+ IncludePlan,
26
+ Query,
27
+ QueryError,
28
+ QueryPlan,
29
+ plan_kinds,
30
+ resolve_hop,
31
+ resolve_query,
32
+ target_of_kind,
33
+ )
34
+ from lorebase_core.tree import kind_partition, relation_partition
35
+ from lorebase_keeper.reading.compiler import (
36
+ Compiler,
37
+ SortKey,
38
+ after,
39
+ cursor_value,
40
+ from_cursor_value,
41
+ json_text,
42
+ order_clauses,
43
+ record_columns,
44
+ )
45
+ from lorebase_keeper.reading.cursors import invalid_cursor, issue, position_of
46
+ from lorebase_keeper.reading.points import ReadPoint, not_found
47
+ from lorebase_keeper.reading.problems import query_problem
48
+ from lorebase_keeper.reading.runtime import ReadRuntime
49
+ from lorebase_keeper.reading.sensitivity import require_comparable_plan
50
+ from lorebase_keeper.reading.snapshots import with_rows
51
+ from lorebase_keeper.reading.tree import TreeReader
52
+ from lorebase_keeper.reading.views import (
53
+ AggregateCount,
54
+ AggregateOut,
55
+ LineageOut,
56
+ LinkCountOut,
57
+ LinkOut,
58
+ RecordOut,
59
+ kind_name,
60
+ link_values,
61
+ record_out,
62
+ record_ref,
63
+ relation_name,
64
+ )
65
+ from lorebase_keeper.tracing.explain import explain
66
+ from lorebase_store.indexes import plan_indexes
67
+ from lorebase_store.materialize.rows import record_title, split_link_key
68
+ from lorebase_store.reads import hydrate_records, links_to_record
69
+ from lorebase_store.repositories import ProvenanceRepository
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class FetchedRecord:
74
+ """A Record row (materialized columns, or tree content with the same keys)."""
75
+
76
+ row: Mapping[Any, Any]
77
+ object_hash: bytes
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class QueryPage:
82
+ items: list[RecordOut]
83
+ next_cursor: str | None
84
+ aggregate: AggregateOut | None = None
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class LinkPageResult:
89
+ items: list[LinkOut]
90
+ next_cursor: str | None
91
+
92
+
93
+ def compiler_for(point: ReadPoint, runtime: ReadRuntime) -> Compiler:
94
+ return Compiler(
95
+ point.domain.id,
96
+ point.layout.branch,
97
+ text_search_config=runtime.store_settings.materialize.text_search_config,
98
+ indexes=plan_indexes(point.schema).by_attribute(),
99
+ layout=point.layout,
100
+ path_fanout=runtime.store_settings.traverse.path_fanout,
101
+ )
102
+
103
+
104
+ def require_kind(point: ReadPoint, api_name: str) -> Kind:
105
+ """The Kind named `api_name`: 404 when unknown, or invisible to the caller (design 13.3);
106
+ 403 when the caller may not read it and invisible Kinds are off."""
107
+ point.scope.require([api_name])
108
+ kind = point.schema.kind_by_api_name(api_name)
109
+ if kind is None:
110
+ raise not_found(f"Kind {api_name}")
111
+ return kind
112
+
113
+
114
+ # Single Record --------------------------------------------------------------------------------
115
+
116
+
117
+ async def fetch_record(
118
+ session: AsyncSession, runtime: ReadRuntime, point: ReadPoint, kind: Kind, key: str
119
+ ) -> FetchedRecord:
120
+ """The Record `(kind, key)` at the read point; 404 when absent."""
121
+ if point.materialized:
122
+ r = point.layout.records()
123
+ found = (
124
+ (
125
+ await session.execute(
126
+ select(*record_columns(r)).where(
127
+ r.c.domain_id == point.domain.id,
128
+ r.c.branch == point.layout.branch,
129
+ r.c.kind_id == kind.id,
130
+ r.c.key == key,
131
+ )
132
+ )
133
+ )
134
+ .mappings()
135
+ .one_or_none()
136
+ )
137
+ if found is None:
138
+ raise not_found(f"Record {kind.api_name}/{key}")
139
+ return FetchedRecord(found, bytes(found["object_hash"]))
140
+ reader = TreeReader(session, runtime, point.domain.id, point.revision.root)
141
+ digest = await reader.get(kind_partition(kind.id), key)
142
+ if digest is None:
143
+ raise not_found(f"Record {kind.api_name}/{key}")
144
+ body = (await reader.bodies([digest]))[digest]
145
+ return FetchedRecord(tree_record_row(kind, key, body), digest)
146
+
147
+
148
+ def tree_record_row(kind: Kind, key: str, body: Mapping[str, Any]) -> dict[str, Any]:
149
+ values = dict(body.get("values", {}))
150
+ return {
151
+ "kind_id": kind.id,
152
+ "key": key,
153
+ "values": values,
154
+ "title": record_title(kind, values),
155
+ }
156
+
157
+
158
+ async def get_record(
159
+ session: AsyncSession,
160
+ runtime: ReadRuntime,
161
+ point: ReadPoint,
162
+ kind: Kind,
163
+ key: str,
164
+ *,
165
+ links: bool,
166
+ lineage: bool,
167
+ ) -> tuple[RecordOut, FetchedRecord]:
168
+ """A Record with, on request, its Link counts and the lineage of its values."""
169
+ if links or lineage:
170
+ point = await with_rows(session, runtime, point, "The links and lineage summaries")
171
+ fetched = await fetch_record(session, runtime, point, kind, key)
172
+ record = record_out(point.schema, fetched.row, masking=point.masking)
173
+ if links:
174
+ record.links = await link_counts(session, runtime, point, kind, fetched.row)
175
+ if lineage:
176
+ record.lineage = await lineage_summary(session, runtime, point, kind, fetched.row)
177
+ return record, fetched
178
+
179
+
180
+ async def link_counts(
181
+ session: AsyncSession,
182
+ runtime: ReadRuntime,
183
+ point: ReadPoint,
184
+ kind: Kind,
185
+ row: Mapping[Any, Any],
186
+ ) -> list[LinkCountOut]:
187
+ """Number of Links per Relation and direction touching the Record, path Derivations
188
+ included (a path only when the caller may read every Kind it crosses)."""
189
+ key = row["key"]
190
+ counts: list[LinkCountOut] = []
191
+ explicit: dict[LinkDirection, list[Relation]] = defaultdict(list)
192
+ for relation in sorted(point.schema.relations_of(kind.id), key=lambda r: r.api_name):
193
+ for direction in LinkDirection:
194
+ if relation.endpoint(direction) != kind.id:
195
+ continue
196
+ far = point.schema.kinds.get(relation.far_endpoint(direction))
197
+ if far is None or not point.scope.allows(far.api_name):
198
+ continue # Links to Kinds the caller may not read are not counted
199
+ if relation.backing is RelationBacking.FOREIGN_KEY:
200
+ count = await _foreign_key_count(session, point, relation, direction, row)
201
+ counts.append(
202
+ LinkCountOut(
203
+ relation=relation_name(relation, direction),
204
+ direction=direction,
205
+ count=count,
206
+ )
207
+ )
208
+ else:
209
+ explicit[direction].append(relation)
210
+ links = point.layout.links()
211
+ for direction, relations in explicit.items():
212
+ near = links.c.from_key if direction is LinkDirection.FORWARD else links.c.to_key
213
+ result = await session.execute(
214
+ select(links.c.relation_id, func.count())
215
+ .where(
216
+ links.c.domain_id == point.domain.id,
217
+ links.c.branch == point.layout.branch,
218
+ links.c.relation_id.in_([r.id for r in relations]),
219
+ near == key,
220
+ )
221
+ .group_by(links.c.relation_id)
222
+ )
223
+ found = {relation_id: int(count) for relation_id, count in result}
224
+ counts.extend(
225
+ LinkCountOut(
226
+ relation=relation_name(r, direction), direction=direction, count=found.get(r.id, 0)
227
+ )
228
+ for r in relations
229
+ )
230
+ counts.extend(await _path_counts(session, runtime, point, kind, key))
231
+ return sorted(counts, key=lambda c: (c.relation, c.direction))
232
+
233
+
234
+ async def _path_counts(
235
+ session: AsyncSession, runtime: ReadRuntime, point: ReadPoint, kind: Kind, key: str
236
+ ) -> list[LinkCountOut]:
237
+ """Link counts of the path Derivations starting or ending at the Record's Kind, stored
238
+ or composed like their Links (`Compiler.link_source`)."""
239
+ target = target_of_kind(point.schema, kind.api_name)
240
+ assert target is not None
241
+ counts: list[LinkCountOut] = []
242
+ compiler: Compiler | None = None
243
+ for path in sorted(point.schema.path_relations().values(), key=lambda p: p.id):
244
+ for direction in LinkDirection:
245
+ name = relation_name(path.relation, direction)
246
+ if not name or path.relation.endpoint(direction) != kind.id:
247
+ continue
248
+ hop = resolve_hop(point.schema, target, name)
249
+ if isinstance(hop, str) or not all(point.scope.allows(k) for k in hop.kinds_read):
250
+ continue # paths crossing Kinds the caller may not read are not counted
251
+ compiler = compiler or compiler_for(point, runtime)
252
+ source = compiler.link_source(hop)
253
+ count = await session.scalar(
254
+ select(func.count())
255
+ .select_from(source.rows)
256
+ .where(source.scope, source.near == literal(key))
257
+ )
258
+ counts.append(LinkCountOut(relation=name, direction=direction, count=int(count or 0)))
259
+ return counts
260
+
261
+
262
+ async def _foreign_key_count(
263
+ session: AsyncSession,
264
+ point: ReadPoint,
265
+ relation: Relation,
266
+ direction: LinkDirection,
267
+ row: Mapping[Any, Any],
268
+ ) -> int:
269
+ attribute = relation.foreign_key_attribute
270
+ assert attribute is not None
271
+ if direction is LinkDirection.FORWARD:
272
+ return 1 if attribute in (row.get("values") or {}) else 0
273
+ r = point.layout.records()
274
+ count = await session.scalar(
275
+ select(func.count()).where(
276
+ r.c.domain_id == point.domain.id,
277
+ r.c.branch == point.layout.branch,
278
+ r.c.kind_id == relation.from_kind,
279
+ json_text(r.c["values"], attribute) == row["key"],
280
+ )
281
+ )
282
+ return int(count or 0)
283
+
284
+
285
+ async def lineage_summary(
286
+ session: AsyncSession,
287
+ runtime: ReadRuntime,
288
+ point: ReadPoint,
289
+ kind: Kind,
290
+ row: Mapping[Any, Any],
291
+ ) -> dict[str, LineageOut]:
292
+ """Per Attribute api name, the provenance entry of its last change."""
293
+ lineage: Mapping[str, str] = row.get("lineage") or {}
294
+ entries = await ProvenanceRepository(
295
+ session, point.domain.id, writes=runtime.store_settings.writes
296
+ ).get_many(lineage.values())
297
+ summary: dict[str, LineageOut] = {}
298
+ for attribute_id, provenance_id in lineage.items():
299
+ attribute = kind.attributes.get(attribute_id)
300
+ if attribute is None:
301
+ continue
302
+ entry = entries.get(provenance_id)
303
+ summary[attribute.api_name] = LineageOut(
304
+ provenance_id=provenance_id,
305
+ origin_type=None if entry is None else entry.origin_type,
306
+ actor=None if entry is None else entry.actor,
307
+ at=None if entry is None else entry.at,
308
+ )
309
+ return summary
310
+
311
+
312
+ # Lists and queries ------------------------------------------------------------------------
313
+
314
+
315
+ def plan_query(query: Query, point: ReadPoint, runtime: ReadRuntime, prefix: str) -> QueryPlan:
316
+ """The resolved query, once every Kind it reads is one the caller may read."""
317
+ if query.kind is not None:
318
+ point.scope.precheck([query.kind])
319
+ try:
320
+ plan = resolve_query(query, point.schema, runtime.read.query_limits())
321
+ except QueryError as error:
322
+ raise query_problem(error, prefix) from None
323
+ point.scope.require(plan_kinds(plan))
324
+ require_comparable_plan(point.schema, point.masking, plan, prefix)
325
+ return plan
326
+
327
+
328
+ def _key_ordered(plan: QueryPlan) -> bool:
329
+ order = plan.order
330
+ return not order or (
331
+ len(order) == 1 and order[0].field.name == KEY_FIELD and not order[0].descending
332
+ )
333
+
334
+
335
+ async def run_query(
336
+ session: AsyncSession,
337
+ runtime: ReadRuntime,
338
+ point: ReadPoint,
339
+ plan: QueryPlan,
340
+ *,
341
+ shape: str,
342
+ cursor_pointer: str,
343
+ ) -> QueryPage:
344
+ """A page of the Records matching a resolved query (plus includes and aggregate)."""
345
+ if not point.materialized:
346
+ if plan.where is None and not plan.include and not plan.aggregate and _key_ordered(plan):
347
+ return await _tree_list(session, runtime, point, plan, shape, cursor_pointer)
348
+ what = "Filtering, ordering, includes and aggregates"
349
+ point = await with_rows(session, runtime, point, what)
350
+ compiler = compiler_for(point, runtime)
351
+ r = compiler.records("r")
352
+ target = plan.target
353
+ keys = compiler.sort_keys(r, plan.order, target)
354
+ items: list[RecordOut] = []
355
+ next_cursor = None
356
+ if plan.limit > 0:
357
+ query: Select[Any] = select(
358
+ *record_columns(r), *(k.expression.label(f"s{i}") for i, k in enumerate(keys))
359
+ ).where(compiler.record_scope(r, target))
360
+ if plan.where is not None:
361
+ query = query.where(compiler.condition(r, plan.where, target))
362
+ position = position_of(plan.cursor, shape, cursor_pointer)
363
+ if position is not None:
364
+ query = query.where(after(keys, _decode_position(keys, position, cursor_pointer)))
365
+ query = query.order_by(*order_clauses(keys)).limit(plan.limit + 1)
366
+ await explain(session, query, point.domain.id, "query")
367
+ rows = list((await session.execute(query)).mappings())
368
+ if len(rows) > plan.limit:
369
+ rows = rows[: plan.limit]
370
+ last = rows[-1]
371
+ next_cursor = issue(shape, [cursor_value(last[f"s{i}"]) for i in range(len(keys))])
372
+ items = [
373
+ record_out(point.schema, row, masking=point.masking, select=plan.select) for row in rows
374
+ ]
375
+ for include in plan.include:
376
+ await _include(session, compiler, point, include, rows, items)
377
+ aggregate = None
378
+ if plan.aggregate is not None:
379
+ aggregate = await _aggregate(session, runtime, compiler, point, plan)
380
+ return QueryPage(items, next_cursor, aggregate)
381
+
382
+
383
+ def _decode_position(keys: Sequence[SortKey], position: list[Any], pointer: str) -> list[Any]:
384
+ if len(position) != len(keys):
385
+ raise invalid_cursor(pointer)
386
+ try:
387
+ return [from_cursor_value(v, k.type) for k, v in zip(keys, position, strict=True)]
388
+ except ValueError:
389
+ raise invalid_cursor(pointer) from None
390
+
391
+
392
+ async def _include(
393
+ session: AsyncSession,
394
+ compiler: Compiler,
395
+ point: ReadPoint,
396
+ include: IncludePlan,
397
+ rows: Sequence[Mapping[Any, Any]],
398
+ items: list[RecordOut],
399
+ ) -> None:
400
+ hop = include.hop
401
+ near = [row["key"] for row in rows if row["kind_id"] == hop.near_kind]
402
+ found: dict[str, list[RecordOut]] = defaultdict(list)
403
+ if near:
404
+ query = compiler.neighbors(hop, near, where=include.where, per_near=include.limit)
405
+ for row in (await session.execute(query)).mappings():
406
+ found[row["near_key"]].append(
407
+ record_out(point.schema, row, masking=point.masking, select=include.select)
408
+ )
409
+ for row, item in zip(rows, items, strict=True):
410
+ if row["kind_id"] != hop.near_kind:
411
+ continue
412
+ item.included = item.included or {}
413
+ item.included[hop.name] = found.get(row["key"], [])
414
+
415
+
416
+ async def _aggregate(
417
+ session: AsyncSession,
418
+ runtime: ReadRuntime,
419
+ compiler: Compiler,
420
+ point: ReadPoint,
421
+ plan: QueryPlan,
422
+ ) -> AggregateOut:
423
+ r = compiler.records("r")
424
+ target = plan.target
425
+ assert plan.aggregate is not None
426
+ group = plan.aggregate.group_by
427
+ columns: list[Any] = [r.c.kind_id]
428
+ if group is not None:
429
+ columns.append(compiler.field_json(r, group).label("value"))
430
+ query = select(*columns, func.count().label("n")).where(compiler.record_scope(r, target))
431
+ if plan.where is not None:
432
+ query = query.where(compiler.condition(r, plan.where, target))
433
+ cap = runtime.read.query.max_aggregate_groups
434
+ # Grouped by output position: the group expression binds its Attribute id as a parameter,
435
+ # so repeating it in GROUP BY would not match the selected column.
436
+ groups: list[Any] = [literal_column(str(i + 1)) for i in range(len(columns))]
437
+ query = query.group_by(*groups).order_by(*groups).limit(cap + 1)
438
+ rows = list((await session.execute(query)).mappings())
439
+ return AggregateOut(
440
+ counts=[
441
+ AggregateCount(
442
+ kind=kind_name(point.schema, row["kind_id"]),
443
+ value=row["value"] if group is not None else None,
444
+ count=int(row["n"]),
445
+ )
446
+ for row in rows[:cap]
447
+ ],
448
+ truncated=len(rows) > cap,
449
+ )
450
+
451
+
452
+ async def _tree_list(
453
+ session: AsyncSession,
454
+ runtime: ReadRuntime,
455
+ point: ReadPoint,
456
+ plan: QueryPlan,
457
+ shape: str,
458
+ cursor_pointer: str,
459
+ ) -> QueryPage:
460
+ """A page of the target's Records at a Revision, in the order of the head's keyset
461
+ (`Compiler.sort_keys`): by key for one Kind; for a Trait by Kind id and key, or by key
462
+ and Kind id when ordered by key. Cursors carry the same positions as at the head."""
463
+ kinds = plan.target.kinds
464
+ by_key = len(kinds) == 1 or bool(plan.order)
465
+ reader = TreeReader(session, runtime, point.domain.id, point.revision.root)
466
+
467
+ def position(kind: Kind, key: str) -> list[str]:
468
+ if len(kinds) == 1:
469
+ return [key]
470
+ return [key, kind.id] if by_key else [kind.id, key]
471
+
472
+ entries = [
473
+ (position(kind, entry.key), kind, entry)
474
+ for kind in kinds
475
+ for entry in await reader.entries(kind_partition(kind.id))
476
+ ]
477
+ entries.sort(key=lambda item: item[0])
478
+ after_position = position_of(plan.cursor, shape, cursor_pointer)
479
+ if after_position is not None:
480
+ width = 1 if len(kinds) == 1 else 2
481
+ if len(after_position) != width or not all(isinstance(p, str) for p in after_position):
482
+ raise invalid_cursor(cursor_pointer)
483
+ entries = [item for item in entries if item[0] > after_position]
484
+ page = entries[: plan.limit]
485
+ bodies = await reader.bodies(entry.object_hash for _, _, entry in page)
486
+ items = [
487
+ record_out(
488
+ point.schema,
489
+ tree_record_row(kind, entry.key, bodies[entry.object_hash]),
490
+ masking=point.masking,
491
+ select=plan.select,
492
+ )
493
+ for _, kind, entry in page
494
+ ]
495
+ more = len(entries) > plan.limit and plan.limit > 0
496
+ return QueryPage(items, issue(shape, page[-1][0]) if more else None)
497
+
498
+
499
+ # Links ----------------------------------------------------------------------------------
500
+
501
+
502
+ def require_hop(point: ReadPoint, kind: Kind, name: str) -> Hop:
503
+ target = target_of_kind(point.schema, kind.api_name)
504
+ assert target is not None
505
+ hop = resolve_hop(point.schema, target, name)
506
+ if isinstance(hop, str):
507
+ raise not_found(f"Relation {name} of Kind {kind.api_name}")
508
+ point.scope.require([kind.api_name, *sorted(hop.kinds_read)])
509
+ return hop
510
+
511
+
512
+ def _link_out(
513
+ point: ReadPoint, hop: Hop, near: str, far: str, values: Any, title: str | None = None
514
+ ) -> LinkOut:
515
+ forward = hop.direction is LinkDirection.FORWARD
516
+ return LinkOut(
517
+ relation=relation_name(hop.relation, hop.direction),
518
+ direction=hop.direction,
519
+ from_key=near if forward else far,
520
+ to_key=far if forward else near,
521
+ values=link_values(point.schema, hop.relation, values, point.masking),
522
+ target=record_ref(point.schema, hop.far.kind_ids[0], far, title, point.masking),
523
+ )
524
+
525
+
526
+ async def record_links(
527
+ session: AsyncSession,
528
+ runtime: ReadRuntime,
529
+ point: ReadPoint,
530
+ kind: Kind,
531
+ key: str,
532
+ relation: str,
533
+ *,
534
+ limit: int,
535
+ cursor: str | None,
536
+ shape: str,
537
+ ) -> LinkPageResult:
538
+ """Links of one Record along a Relation (forward or inverse name), by far key."""
539
+ hop = require_hop(point, kind, relation)
540
+ await fetch_record(session, runtime, point, kind, key)
541
+ position = position_of(cursor, shape, "/query/cursor")
542
+ if position is not None and (len(position) != 1 or not isinstance(position[0], str)):
543
+ raise invalid_cursor("/query/cursor")
544
+ after_key = None if position is None else position[0]
545
+ if not point.materialized:
546
+ pairs = await _tree_links(session, runtime, point, hop, key)
547
+ pairs = [p for p in pairs if after_key is None or p[0] > after_key]
548
+ page = pairs[:limit]
549
+ items = [_link_out(point, hop, key, far, values) for far, values in page]
550
+ more = len(pairs) > limit
551
+ return LinkPageResult(items, issue(shape, [page[-1][0]]) if more and page else None)
552
+ composed = hop.path is not None and not hop.path.materialized
553
+ if hop.direction is LinkDirection.INVERSE and not composed:
554
+ return await _inbound_links(session, runtime, point, hop, key, limit, after_key, shape)
555
+ compiler = compiler_for(point, runtime)
556
+ source = compiler.link_source(hop)
557
+ far = point.layout.records("far")
558
+ query = (
559
+ select(source.far.label("far_key"), source.values.label("link_values"), far.c.title)
560
+ .select_from(source.rows)
561
+ .outerjoin(
562
+ far,
563
+ and_(
564
+ far.c.domain_id == literal(point.domain.id),
565
+ far.c.branch == literal(point.layout.branch),
566
+ far.c.kind_id == literal(hop.far.kind_ids[0]),
567
+ far.c.key == source.far,
568
+ ),
569
+ )
570
+ .where(source.scope, source.near == literal(key))
571
+ .order_by(source.far)
572
+ .limit(limit + 1)
573
+ )
574
+ if after_key is not None:
575
+ query = query.where(source.far > literal(after_key))
576
+ rows = list((await session.execute(query)).mappings())
577
+ more = len(rows) > limit
578
+ rows = rows[:limit]
579
+ items = [
580
+ _link_out(point, hop, key, row["far_key"], row["link_values"], row["title"]) for row in rows
581
+ ]
582
+ return LinkPageResult(items, issue(shape, [rows[-1]["far_key"]]) if more else None)
583
+
584
+
585
+ async def _inbound_links(
586
+ session: AsyncSession,
587
+ runtime: ReadRuntime,
588
+ point: ReadPoint,
589
+ hop: Hop,
590
+ key: str,
591
+ limit: int,
592
+ after_key: str | None,
593
+ shape: str,
594
+ ) -> LinkPageResult:
595
+ """Links pointing at the Record along an inverse name, through the store primitive
596
+ (`links_to_record`: the reverse Link index, or the from Kind's per-Attribute index for
597
+ foreign-key Relations, with index plans under row-level security)."""
598
+ domain_id, branch, store = point.domain.id, point.layout, runtime.store
599
+ links = await links_to_record(
600
+ session, domain_id, branch, hop.relation, key, limit=limit, after=after_key, runtime=store
601
+ )
602
+ more = len(links) == limit and bool(
603
+ await links_to_record(
604
+ session,
605
+ domain_id,
606
+ branch,
607
+ hop.relation,
608
+ key,
609
+ limit=1,
610
+ after=links[-1].from_key,
611
+ runtime=store,
612
+ )
613
+ )
614
+ far_kind = hop.far.kind_ids[0]
615
+ found = await hydrate_records(
616
+ session, domain_id, branch, [(far_kind, link.from_key) for link in links], runtime=store
617
+ )
618
+ items = []
619
+ for link in links:
620
+ record = found.get((far_kind, link.from_key))
621
+ title = None if record is None else record.title
622
+ items.append(_link_out(point, hop, key, link.from_key, link.values, title))
623
+ return LinkPageResult(items, issue(shape, [links[-1].from_key]) if more else None)
624
+
625
+
626
+ async def _tree_links(
627
+ session: AsyncSession, runtime: ReadRuntime, point: ReadPoint, hop: Hop, key: str
628
+ ) -> list[tuple[str, Mapping[str, Any]]]:
629
+ """`(far_key, link values)` of a Record's Links at a Revision, sorted by far key."""
630
+ reader = TreeReader(session, runtime, point.domain.id, point.revision.root)
631
+ if hop.path is not None:
632
+ pairs = await _tree_path_pairs(reader, runtime, hop.path)
633
+ forward = hop.direction is LinkDirection.FORWARD
634
+ return sorted((t if forward else f, {}) for f, t in pairs if (f if forward else t) == key)
635
+ relation = hop.relation
636
+ forward = hop.direction is LinkDirection.FORWARD
637
+ if hop.foreign_key:
638
+ attribute = relation.foreign_key_attribute
639
+ assert attribute is not None
640
+ if forward:
641
+ digest = await reader.get(kind_partition(relation.from_kind), key)
642
+ body = {} if digest is None else (await reader.bodies([digest]))[digest]
643
+ target = body.get("values", {}).get(attribute)
644
+ return [] if target is None else [(str(target), {})]
645
+ entries = await reader.entries(kind_partition(relation.from_kind))
646
+ bodies = await reader.bodies(e.object_hash for e in entries)
647
+ return [
648
+ (e.key, {})
649
+ for e in entries
650
+ if bodies[e.object_hash].get("values", {}).get(attribute) == key
651
+ ]
652
+ entries = await reader.entries(relation_partition(relation.id))
653
+ matched: list[tuple[str, bytes]] = []
654
+ for entry in entries:
655
+ from_key, to_key = split_link_key(entry.key)
656
+ near, far = (from_key, to_key) if forward else (to_key, from_key)
657
+ if near == key:
658
+ matched.append((far, entry.object_hash))
659
+ bodies = await reader.bodies(digest for _, digest in matched)
660
+ return sorted((far, bodies[d].get("values", {})) for far, d in matched)
661
+
662
+
663
+ async def relation_links(
664
+ session: AsyncSession,
665
+ runtime: ReadRuntime,
666
+ point: ReadPoint,
667
+ relation_name_: str,
668
+ *,
669
+ from_key: str | None,
670
+ to_key: str | None,
671
+ limit: int,
672
+ cursor: str | None,
673
+ shape: str,
674
+ ) -> LinkPageResult:
675
+ """Links of a Relation ordered by `(from_key, to_key)`, optionally from or to one key."""
676
+ found = point.schema.relation_by_api_name(relation_name_)
677
+ along = point.schema.path_by_api_name(relation_name_)
678
+ if found is None and along is not None:
679
+ found = (along[0].relation, along[1])
680
+ if found is None:
681
+ raise not_found(f"Relation {relation_name_}")
682
+ relation, _ = found
683
+ from_kind = point.schema.kinds.get(relation.from_kind)
684
+ if from_kind is None:
685
+ raise not_found(f"Relation {relation_name_}")
686
+ hop = require_hop(point, from_kind, relation.api_name)
687
+ position = position_of(cursor, shape, "/query/cursor")
688
+ if position is not None and (
689
+ len(position) != 2 or not all(isinstance(p, str) for p in position)
690
+ ):
691
+ raise invalid_cursor("/query/cursor")
692
+ if not point.materialized:
693
+ pairs = await _tree_relation_links(session, runtime, point, hop)
694
+ pairs = [
695
+ p
696
+ for p in pairs
697
+ if (from_key is None or p[0] == from_key)
698
+ and (to_key is None or p[1] == to_key)
699
+ and (position is None or (p[0], p[1]) > (position[0], position[1]))
700
+ ]
701
+ page = pairs[:limit]
702
+ items = [_link_out(point, hop, f, t, v) for f, t, v in page]
703
+ more = len(pairs) > limit
704
+ return LinkPageResult(items, issue(shape, list(page[-1][:2])) if more and page else None)
705
+ compiler = compiler_for(point, runtime)
706
+ source = compiler.link_source(hop)
707
+ query = (
708
+ select(
709
+ source.near.label("from_key"),
710
+ source.far.label("to_key"),
711
+ source.values.label("link_values"),
712
+ )
713
+ .select_from(source.rows)
714
+ .where(source.scope)
715
+ .order_by(source.near, source.far)
716
+ .limit(limit + 1)
717
+ )
718
+ if from_key is not None:
719
+ query = query.where(source.near == literal(from_key))
720
+ if to_key is not None:
721
+ query = query.where(source.far == literal(to_key))
722
+ if position is not None:
723
+ p_from, p_to = literal(position[0]), literal(position[1])
724
+ query = query.where((source.near > p_from) | and_(source.near == p_from, source.far > p_to))
725
+ rows = list((await session.execute(query)).mappings())
726
+ more = len(rows) > limit
727
+ rows = rows[:limit]
728
+ items = [_link_out(point, hop, r["from_key"], r["to_key"], r["link_values"]) for r in rows]
729
+ next_cursor = issue(shape, [rows[-1]["from_key"], rows[-1]["to_key"]]) if more else None
730
+ return LinkPageResult(items, next_cursor)
731
+
732
+
733
+ async def _tree_relation_links(
734
+ session: AsyncSession, runtime: ReadRuntime, point: ReadPoint, hop: Hop
735
+ ) -> list[tuple[str, str, Mapping[str, Any]]]:
736
+ reader = TreeReader(session, runtime, point.domain.id, point.revision.root)
737
+ if hop.path is not None:
738
+ return [(f, t, {}) for f, t in sorted(await _tree_path_pairs(reader, runtime, hop.path))]
739
+ return await _tree_pairs(reader, hop.relation)
740
+
741
+
742
+ async def _tree_pairs(
743
+ reader: TreeReader, relation: Relation
744
+ ) -> list[tuple[str, str, Mapping[str, Any]]]:
745
+ """Every Link of a Relation at a Revision as `(from_key, to_key, values)`, sorted."""
746
+ if relation.backing is RelationBacking.FOREIGN_KEY:
747
+ attribute = relation.foreign_key_attribute
748
+ assert attribute is not None
749
+ entries = await reader.entries(kind_partition(relation.from_kind))
750
+ bodies = await reader.bodies(e.object_hash for e in entries)
751
+ pairs: list[tuple[str, str, Mapping[str, Any]]] = []
752
+ for e in entries:
753
+ target = bodies[e.object_hash].get("values", {}).get(attribute)
754
+ if target is not None:
755
+ pairs.append((e.key, str(target), {}))
756
+ return sorted(pairs, key=lambda p: (p[0], p[1]))
757
+ entries = await reader.entries(relation_partition(relation.id))
758
+ bodies = await reader.bodies(e.object_hash for e in entries)
759
+ out = [(*split_link_key(e.key), bodies[e.object_hash].get("values", {})) for e in entries]
760
+ return sorted(out, key=lambda p: (p[0], p[1]))
761
+
762
+
763
+ async def _tree_path_pairs(
764
+ reader: TreeReader, runtime: ReadRuntime, path: PathRelation
765
+ ) -> set[tuple[str, str]]:
766
+ """Every Link of a path Derivation at a Revision, composed from the Links of its steps
767
+ there (`lorebase_core.model.paths`; materialized or not, the tree holds no path Links)."""
768
+ maps: list[dict[str, list[str]]] = []
769
+ for step in path.steps:
770
+ forward = step.direction is LinkDirection.FORWARD
771
+ by_near: dict[str, list[str]] = defaultdict(list)
772
+ for from_key, to_key, _ in await _tree_pairs(reader, step.relation):
773
+ near, far = (from_key, to_key) if forward else (to_key, from_key)
774
+ by_near[near].append(far)
775
+ maps.append(by_near)
776
+ steps = [
777
+ (lambda keys, found=found: {k: found[k] for k in keys if k in found}) for found in maps
778
+ ]
779
+ reached = walk(sorted(maps[0]), steps, runtime.store_settings.traverse.path_fanout)
780
+ return {(start, far) for start, fars in reached.items() for far in fars}