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,493 @@
1
+ """Read API (design 8, 11.1): Records, Links, query, traversal, neighborhoods and search.
2
+
3
+ Every read takes `branch` (default: the Domain's default branch) and `at`, a Revision id or
4
+ tag, served from the Revision tree (key reads and listings ordered by key) or from a snapshot
5
+ of the Revision (everything else, `reading.snapshots`). Responses carry the Revision they were
6
+ served from in `revision` and in the `Lorebase-Revision` header, and `served_from`
7
+ (`materialized` or `tree`). GET reads carry an `ETag` bound to that Revision, answer
8
+ `If-None-Match` with 304 and are cached per Revision (`reading.caching`); at a branch head
9
+ the 304 is decided from the head Revision alone (`unchanged_head`), before the read point is
10
+ resolved. POST reads carry an `ETag` too.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import Annotated, Any
17
+
18
+ from fastapi import APIRouter, Depends, Query, Request, Response
19
+ from pydantic import BaseModel, ValidationError
20
+ from sqlalchemy.ext.asyncio import AsyncSession
21
+
22
+ from lorebase_core.query import Query as DslQuery
23
+ from lorebase_keeper.errors import Problem, ValidationProblem
24
+ from lorebase_keeper.reading import graph, record_history, records, search
25
+ from lorebase_keeper.reading.caching import CACHE_CONTROL, JSON, respond
26
+ from lorebase_keeper.reading.cursors import fingerprint
27
+ from lorebase_keeper.reading.points import (
28
+ REVISION_HEADER,
29
+ ReadPoint,
30
+ read_guard,
31
+ resolve_point,
32
+ unchanged_head,
33
+ )
34
+ from lorebase_keeper.reading.problems import invalid, request_problem
35
+ from lorebase_keeper.reading.record_history import RecordHistoryOut, RecordLineageOut
36
+ from lorebase_keeper.reading.requests import (
37
+ AT,
38
+ NeighborhoodRequest,
39
+ QueryRequest,
40
+ SearchRequest,
41
+ TraverseRequest,
42
+ )
43
+ from lorebase_keeper.reading.runtime import ReadRuntime, ReadRuntimeDep, ReadSessionDep
44
+ from lorebase_keeper.reading.views import (
45
+ LinkPage,
46
+ NeighborhoodResponse,
47
+ QueryResponse,
48
+ RecordPage,
49
+ RecordResponse,
50
+ SearchResponse,
51
+ TraverseResponse,
52
+ served,
53
+ )
54
+ from lorebase_keeper.services.reads import ReadGuard
55
+
56
+ router = APIRouter(tags=["read"])
57
+
58
+ BASE = "/domains/{domain_id}"
59
+ _ERRORS: dict[int | str, dict[str, Any]] = {
60
+ 403: {
61
+ "model": Problem,
62
+ "description": "The caller lacks data.read for the branch or Revision read.",
63
+ },
64
+ 404: {"model": Problem, "description": "Domain, branch, Revision, Kind or Record not found."},
65
+ 422: {"model": ValidationProblem, "description": "Invalid request or query."},
66
+ }
67
+
68
+ DataRead = Annotated[ReadGuard, Depends(read_guard())]
69
+ Branch = Annotated[
70
+ str | None, Query(description="Branch to read; default: the Domain's default branch.")
71
+ ]
72
+ At = Annotated[
73
+ str | None,
74
+ Query(description=AT),
75
+ ]
76
+ Limit = Annotated[int | None, Query(ge=1, description="Page size.")]
77
+ Cursor = Annotated[str | None, Query(max_length=4096, description="Opaque page cursor.")]
78
+
79
+
80
+ _NOT_MODIFIED: dict[int | str, dict[str, Any]] = {
81
+ 304: {"description": "Not modified: If-None-Match names the current ETag."}
82
+ }
83
+
84
+
85
+ def _mark(response: Response, point: ReadPoint, body: BaseModel) -> None:
86
+ """Headers of a POST read: Revision, and an ETag of the Revision and the request body."""
87
+ digest = fingerprint(body.model_dump(mode="json"), point.variant)
88
+ response.headers[REVISION_HEADER] = point.revision_hex
89
+ response.headers["ETag"] = f'"{point.revision_hex}.{digest}"'
90
+ response.headers["Cache-Control"] = CACHE_CONTROL
91
+
92
+
93
+ async def _answer(
94
+ request: Request, runtime: ReadRuntime, point: ReadPoint, produce: Any, session: AsyncSession
95
+ ) -> Response:
96
+ return await respond(
97
+ request,
98
+ runtime.cache,
99
+ domain_id=point.domain.id,
100
+ revision=point.revision_hex,
101
+ produce=produce,
102
+ time_travel=not point.materialized,
103
+ headers={REVISION_HEADER: point.revision_hex},
104
+ variant=point.variant,
105
+ disclose=point.disclosure(session, runtime).send,
106
+ )
107
+
108
+
109
+ async def _sent(
110
+ session: AsyncSession, runtime: ReadRuntime, point: ReadPoint, body: BaseModel, out: BaseModel
111
+ ) -> Response:
112
+ """A POST read's response: `out` with its vault references resolved, and the headers of
113
+ `_mark`."""
114
+ content = await point.disclosure(session, runtime).model(out)
115
+ response = Response(content, media_type=JSON)
116
+ _mark(response, point, body)
117
+ return response
118
+
119
+
120
+ def _limit(runtime: ReadRuntime, limit: int | None) -> int:
121
+ bounds = runtime.read.records
122
+ effective = bounds.default_limit if limit is None else limit
123
+ if effective > bounds.max_limit:
124
+ raise invalid("/query/limit", f"must not exceed {bounds.max_limit}", "limit_exceeded")
125
+ return effective
126
+
127
+
128
+ def _shape(query: DslQuery) -> str:
129
+ return fingerprint("query", query.model_dump(mode="json", exclude={"cursor", "limit"}))
130
+
131
+
132
+ # Records ----------------------------------------------------------------------------------
133
+
134
+
135
+ @router.get(
136
+ f"{BASE}/kinds/{{kind}}/records",
137
+ summary="List Records of a Kind",
138
+ response_model=RecordPage,
139
+ responses={**_ERRORS, **_NOT_MODIFIED},
140
+ )
141
+ async def list_records(
142
+ domain_id: str,
143
+ kind: str,
144
+ access: DataRead,
145
+ runtime: ReadRuntimeDep,
146
+ session: ReadSessionDep,
147
+ request: Request,
148
+ branch: Branch = None,
149
+ at: At = None,
150
+ where: Annotated[
151
+ str | None, Query(description="DSL predicate as JSON (design 8.2).", max_length=65536)
152
+ ] = None,
153
+ order_by: Annotated[
154
+ str | None, Query(description="Comma-separated `name[:asc|desc]` terms.")
155
+ ] = None,
156
+ select: Annotated[str | None, Query(description="Comma-separated field names.")] = None,
157
+ limit: Limit = None,
158
+ cursor: Cursor = None,
159
+ ) -> Response:
160
+ """Records of a Kind filtered with the DSL, ordered and paginated by keyset."""
161
+ if at is None and (
162
+ unchanged := await unchanged_head(session, runtime, access, request, branch=branch)
163
+ ):
164
+ return unchanged
165
+ point = await resolve_point(session, runtime, access, branch=branch, at=at)
166
+ records.require_kind(point, kind)
167
+ document: dict[str, Any] = {"kind": kind, "limit": _limit(runtime, limit), "cursor": cursor}
168
+ if where:
169
+ try:
170
+ document["where"] = json.loads(where)
171
+ except ValueError:
172
+ raise invalid("/query/where", "is not valid JSON") from None
173
+ if order_by:
174
+ terms = []
175
+ for term in order_by.split(","):
176
+ name, _, direction = term.strip().partition(":")
177
+ terms.append({"attr": name, "dir": direction or "asc"})
178
+ document["order_by"] = terms
179
+ if select:
180
+ document["select"] = [name.strip() for name in select.split(",") if name.strip()]
181
+ try:
182
+ query = DslQuery.model_validate(document)
183
+ except ValidationError as error:
184
+ raise request_problem(error, "/query") from None
185
+ plan = records.plan_query(query, point, runtime, "/query")
186
+
187
+ async def produce() -> RecordPage:
188
+ page = await records.run_query(
189
+ session, runtime, point, plan, shape=_shape(query), cursor_pointer="/query/cursor"
190
+ )
191
+ return RecordPage(**served(point), items=page.items, next_cursor=page.next_cursor)
192
+
193
+ return await _answer(request, runtime, point, produce, session)
194
+
195
+
196
+ @router.get(
197
+ f"{BASE}/kinds/{{kind}}/records/{{key}}/links/{{relation}}",
198
+ summary="List the Links of a Record along a Relation",
199
+ response_model=LinkPage,
200
+ responses={**_ERRORS, **_NOT_MODIFIED},
201
+ )
202
+ async def list_record_links(
203
+ domain_id: str,
204
+ kind: str,
205
+ key: str,
206
+ relation: str,
207
+ access: DataRead,
208
+ runtime: ReadRuntimeDep,
209
+ session: ReadSessionDep,
210
+ request: Request,
211
+ branch: Branch = None,
212
+ at: At = None,
213
+ limit: Limit = None,
214
+ cursor: Cursor = None,
215
+ ) -> Response:
216
+ """Links of the Record along `relation`, a forward or inverse api name, by far key."""
217
+ if at is None and (
218
+ unchanged := await unchanged_head(session, runtime, access, request, branch=branch)
219
+ ):
220
+ return unchanged
221
+ point = await resolve_point(session, runtime, access, branch=branch, at=at)
222
+ found = records.require_kind(point, kind)
223
+ effective = _limit(runtime, limit)
224
+
225
+ async def produce() -> LinkPage:
226
+ page = await records.record_links(
227
+ session,
228
+ runtime,
229
+ point,
230
+ found,
231
+ key,
232
+ relation,
233
+ limit=effective,
234
+ cursor=cursor,
235
+ shape=fingerprint("record-links", kind, key, relation),
236
+ )
237
+ return LinkPage(**served(point), items=page.items, next_cursor=page.next_cursor)
238
+
239
+ return await _answer(request, runtime, point, produce, session)
240
+
241
+
242
+ @router.get(
243
+ f"{BASE}/kinds/{{kind}}/records/{{key}}",
244
+ summary="Get a Record by key",
245
+ response_model=RecordResponse,
246
+ responses={**_ERRORS, **_NOT_MODIFIED},
247
+ )
248
+ async def get_record(
249
+ domain_id: str,
250
+ kind: str,
251
+ key: str,
252
+ access: DataRead,
253
+ runtime: ReadRuntimeDep,
254
+ session: ReadSessionDep,
255
+ request: Request,
256
+ branch: Branch = None,
257
+ at: At = None,
258
+ links: Annotated[bool, Query(description="Add Link counts per Relation.")] = False,
259
+ lineage: Annotated[bool, Query(description="Add the provenance of each value.")] = False,
260
+ ) -> Response:
261
+ """One Record, optionally with its Link counts and the lineage of its values."""
262
+ if at is None and (
263
+ unchanged := await unchanged_head(session, runtime, access, request, branch=branch)
264
+ ):
265
+ return unchanged
266
+ point = await resolve_point(session, runtime, access, branch=branch, at=at)
267
+ found = records.require_kind(point, kind)
268
+
269
+ async def produce() -> RecordResponse:
270
+ record, _ = await records.get_record(
271
+ session, runtime, point, found, key, links=links, lineage=lineage
272
+ )
273
+ return RecordResponse(**served(point), record=record)
274
+
275
+ return await _answer(request, runtime, point, produce, session)
276
+
277
+
278
+ @router.get(
279
+ f"{BASE}/kinds/{{kind}}/records/{{key}}/history",
280
+ summary="History of a Record",
281
+ response_model=RecordHistoryOut,
282
+ responses={**_ERRORS, **_NOT_MODIFIED},
283
+ )
284
+ async def get_record_history(
285
+ domain_id: str,
286
+ kind: str,
287
+ key: str,
288
+ access: DataRead,
289
+ runtime: ReadRuntimeDep,
290
+ session: ReadSessionDep,
291
+ request: Request,
292
+ branch: Branch = None,
293
+ at: At = None,
294
+ values: Annotated[
295
+ bool, Query(description="Add the values before and after each change.")
296
+ ] = False,
297
+ limit: Limit = None,
298
+ cursor: Cursor = None,
299
+ ) -> Response:
300
+ """Revisions that changed the Record on the branch, newest first, with the Attributes each
301
+ changed and their provenance, from the branch history; with `at`, the changes up to that
302
+ Revision."""
303
+ if at is None and (
304
+ unchanged := await unchanged_head(session, runtime, access, request, branch=branch)
305
+ ):
306
+ return unchanged
307
+ point = await resolve_point(session, runtime, access, branch=branch, at=at)
308
+ found = records.require_kind(point, kind)
309
+ bounds = runtime.read.history
310
+ effective = bounds.default_limit if limit is None else limit
311
+ if effective > bounds.max_limit:
312
+ raise invalid("/limit", f"must not exceed {bounds.max_limit}", "limit_exceeded")
313
+
314
+ async def produce() -> RecordHistoryOut:
315
+ return await record_history.record_history(
316
+ session,
317
+ runtime,
318
+ point,
319
+ found,
320
+ key,
321
+ limit=effective,
322
+ cursor=cursor,
323
+ values=values,
324
+ shape=fingerprint("record-history", kind, key, values),
325
+ )
326
+
327
+ return await _answer(request, runtime, point, produce, session)
328
+
329
+
330
+ @router.get(
331
+ f"{BASE}/kinds/{{kind}}/records/{{key}}/lineage",
332
+ summary="Lineage of a Record",
333
+ response_model=RecordLineageOut,
334
+ responses={**_ERRORS, **_NOT_MODIFIED},
335
+ )
336
+ async def get_record_lineage(
337
+ domain_id: str,
338
+ kind: str,
339
+ key: str,
340
+ access: DataRead,
341
+ runtime: ReadRuntimeDep,
342
+ session: ReadSessionDep,
343
+ request: Request,
344
+ branch: Branch = None,
345
+ at: At = None,
346
+ ) -> Response:
347
+ """Where each value of the Record came from: provenance, Revision, Source, Operation,
348
+ extraction evidence and Proposal. At the branch head, or at `at` (from a snapshot)."""
349
+ if at is None and (
350
+ unchanged := await unchanged_head(session, runtime, access, request, branch=branch)
351
+ ):
352
+ return unchanged
353
+ point = await resolve_point(session, runtime, access, branch=branch, at=at)
354
+ found = records.require_kind(point, kind)
355
+
356
+ async def produce() -> RecordLineageOut:
357
+ return await record_history.record_lineage(
358
+ session,
359
+ runtime,
360
+ point,
361
+ found,
362
+ key,
363
+ )
364
+
365
+ return await _answer(request, runtime, point, produce, session)
366
+
367
+
368
+ @router.get(
369
+ f"{BASE}/relations/{{relation}}/links",
370
+ summary="List the Links of a Relation",
371
+ response_model=LinkPage,
372
+ responses={**_ERRORS, **_NOT_MODIFIED},
373
+ )
374
+ async def list_relation_links(
375
+ domain_id: str,
376
+ relation: str,
377
+ access: DataRead,
378
+ runtime: ReadRuntimeDep,
379
+ session: ReadSessionDep,
380
+ request: Request,
381
+ branch: Branch = None,
382
+ at: At = None,
383
+ from_key: Annotated[str | None, Query(description="Only Links from this key.")] = None,
384
+ to_key: Annotated[str | None, Query(description="Only Links to this key.")] = None,
385
+ limit: Limit = None,
386
+ cursor: Cursor = None,
387
+ ) -> Response:
388
+ """Links of a Relation (either api name) ordered by `(from_key, to_key)`."""
389
+ if at is None and (
390
+ unchanged := await unchanged_head(session, runtime, access, request, branch=branch)
391
+ ):
392
+ return unchanged
393
+ point = await resolve_point(session, runtime, access, branch=branch, at=at)
394
+ effective = _limit(runtime, limit)
395
+
396
+ async def produce() -> LinkPage:
397
+ page = await records.relation_links(
398
+ session,
399
+ runtime,
400
+ point,
401
+ relation,
402
+ from_key=from_key,
403
+ to_key=to_key,
404
+ limit=effective,
405
+ cursor=cursor,
406
+ shape=fingerprint("relation-links", relation, from_key, to_key),
407
+ )
408
+ return LinkPage(**served(point), items=page.items, next_cursor=page.next_cursor)
409
+
410
+ return await _answer(request, runtime, point, produce, session)
411
+
412
+
413
+ # Query, traversal, search ---------------------------------------------------------------
414
+
415
+
416
+ @router.post(
417
+ f"{BASE}/query",
418
+ summary="Query Records with the DSL",
419
+ responses=_ERRORS,
420
+ response_model=QueryResponse,
421
+ )
422
+ async def query_records(
423
+ domain_id: str,
424
+ body: QueryRequest,
425
+ access: DataRead,
426
+ runtime: ReadRuntimeDep,
427
+ session: ReadSessionDep,
428
+ ) -> Response:
429
+ """Run a DSL query (design 8.2): filters, Relation predicates, includes, aggregates."""
430
+ point = await resolve_point(session, runtime, access, branch=body.branch, at=body.at)
431
+ query = DslQuery.model_validate(body.model_dump(by_alias=True, exclude={"branch", "at"}))
432
+ plan = records.plan_query(query, point, runtime, "/body")
433
+ page = await records.run_query(
434
+ session, runtime, point, plan, shape=_shape(query), cursor_pointer="/body/cursor"
435
+ )
436
+ out = QueryResponse(
437
+ **served(point), items=page.items, next_cursor=page.next_cursor, aggregate=page.aggregate
438
+ )
439
+ return await _sent(session, runtime, point, body, out)
440
+
441
+
442
+ @router.post(
443
+ f"{BASE}/traverse",
444
+ summary="Traverse Relations from a Record",
445
+ responses=_ERRORS,
446
+ response_model=TraverseResponse,
447
+ )
448
+ async def traverse(
449
+ domain_id: str,
450
+ body: TraverseRequest,
451
+ access: DataRead,
452
+ runtime: ReadRuntimeDep,
453
+ session: ReadSessionDep,
454
+ ) -> Response:
455
+ """Follow a path of Relations, with a filter and a cap per hop (design 8.3)."""
456
+ point = await resolve_point(session, runtime, access, branch=body.branch, at=body.at)
457
+ result = await graph.traverse(session, runtime, point, body)
458
+ return await _sent(session, runtime, point, body, result)
459
+
460
+
461
+ @router.post(
462
+ f"{BASE}/neighborhood",
463
+ summary="Neighborhood of seed Records",
464
+ responses=_ERRORS,
465
+ response_model=NeighborhoodResponse,
466
+ )
467
+ async def neighborhood(
468
+ domain_id: str,
469
+ body: NeighborhoodRequest,
470
+ access: DataRead,
471
+ runtime: ReadRuntimeDep,
472
+ session: ReadSessionDep,
473
+ ) -> Response:
474
+ """Breadth-first expansion from seed Records within depth, node and edge caps."""
475
+ point = await resolve_point(session, runtime, access, branch=body.branch, at=body.at)
476
+ result = await graph.neighborhood(session, runtime, point, body)
477
+ return await _sent(session, runtime, point, body, result)
478
+
479
+
480
+ @router.post(
481
+ f"{BASE}/search", summary="Search Records", responses=_ERRORS, response_model=SearchResponse
482
+ )
483
+ async def search_records(
484
+ domain_id: str,
485
+ body: SearchRequest,
486
+ access: DataRead,
487
+ runtime: ReadRuntimeDep,
488
+ session: ReadSessionDep,
489
+ ) -> Response:
490
+ """Lexical, semantic (caller-supplied vector) or hybrid search (design 8.4)."""
491
+ point = await resolve_point(session, runtime, access, branch=body.branch, at=body.at)
492
+ result = await search.search(session, runtime, point, body)
493
+ return await _sent(session, runtime, point, body, result)