lorebase-keeper 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lorebase_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,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)
|