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,523 @@
|
|
|
1
|
+
"""Revisions, log and diff API (design 6.3, 6.4, 6.7, 11.1).
|
|
2
|
+
|
|
3
|
+
Revisions are named by id (hex), tag or branch (its head). Callers whose grants are narrowed
|
|
4
|
+
to branches read the branches they are granted and Revisions reachable from their heads
|
|
5
|
+
(`services.reads`).
|
|
6
|
+
`GET .../diff` pages through the changes between two Revisions (`format=ndjson` streams all of
|
|
7
|
+
them as JSON lines, after a summary line); changes are expanded to Attribute level for Records
|
|
8
|
+
and Links and to property level for schema elements.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import AsyncIterator, Sequence
|
|
15
|
+
from typing import Annotated, Any, Literal
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, Depends, Query, Request, Response
|
|
18
|
+
from fastapi.responses import StreamingResponse
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
20
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
21
|
+
|
|
22
|
+
from lorebase_core.model import Schema
|
|
23
|
+
from lorebase_core.tree import kind_partition, relation_partition
|
|
24
|
+
from lorebase_keeper.errors import Problem, ValidationProblem
|
|
25
|
+
from lorebase_keeper.reading.caching import respond
|
|
26
|
+
from lorebase_keeper.reading.cursors import fingerprint, invalid_cursor, issue, position_of
|
|
27
|
+
from lorebase_keeper.reading.diff import (
|
|
28
|
+
ChangeOut,
|
|
29
|
+
HashChange,
|
|
30
|
+
PartitionSummary,
|
|
31
|
+
as_hash_change,
|
|
32
|
+
compute_diff,
|
|
33
|
+
expand,
|
|
34
|
+
summarize,
|
|
35
|
+
)
|
|
36
|
+
from lorebase_keeper.reading.history import (
|
|
37
|
+
PartitionCount,
|
|
38
|
+
RevisionOut,
|
|
39
|
+
log,
|
|
40
|
+
partition_counts,
|
|
41
|
+
revision_out,
|
|
42
|
+
)
|
|
43
|
+
from lorebase_keeper.reading.points import (
|
|
44
|
+
REVISION_HEADER,
|
|
45
|
+
open_branch,
|
|
46
|
+
open_domain,
|
|
47
|
+
open_revision,
|
|
48
|
+
read_guard,
|
|
49
|
+
schema_at,
|
|
50
|
+
)
|
|
51
|
+
from lorebase_keeper.reading.problems import invalid
|
|
52
|
+
from lorebase_keeper.reading.runtime import (
|
|
53
|
+
ReadRuntime,
|
|
54
|
+
ReadRuntimeDep,
|
|
55
|
+
ReadSessionDep,
|
|
56
|
+
begin_read,
|
|
57
|
+
)
|
|
58
|
+
from lorebase_keeper.reading.sensitivity import (
|
|
59
|
+
Disclosure,
|
|
60
|
+
Masking,
|
|
61
|
+
default_schema,
|
|
62
|
+
masking_for,
|
|
63
|
+
)
|
|
64
|
+
from lorebase_keeper.services.reads import ReadGuard
|
|
65
|
+
from lorebase_store.repositories import ChangesetRepository, RevisionRepository, RevisionRow
|
|
66
|
+
from lorebase_store.repositories.scope import scope_to_domain
|
|
67
|
+
|
|
68
|
+
router = APIRouter(tags=["revisions"])
|
|
69
|
+
|
|
70
|
+
BASE = "/domains/{domain_id}"
|
|
71
|
+
NDJSON = "application/x-ndjson"
|
|
72
|
+
_ERRORS: dict[int | str, dict[str, Any]] = {
|
|
73
|
+
403: {
|
|
74
|
+
"model": Problem,
|
|
75
|
+
"description": "The caller lacks data.read for the branch or Revision read.",
|
|
76
|
+
},
|
|
77
|
+
404: {"model": Problem, "description": "Domain, branch or Revision not found."},
|
|
78
|
+
422: {"model": ValidationProblem, "description": "Invalid request."},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
DataRead = Annotated[ReadGuard, Depends(read_guard())]
|
|
82
|
+
_NOT_MODIFIED: dict[int | str, dict[str, Any]] = {
|
|
83
|
+
304: {"description": "Not modified: If-None-Match names the current ETag."}
|
|
84
|
+
}
|
|
85
|
+
Limit = Annotated[int | None, Query(ge=1, description="Page size.")]
|
|
86
|
+
Cursor = Annotated[str | None, Query(max_length=4096, description="Opaque page cursor.")]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class LogPage(BaseModel):
|
|
90
|
+
branch: str
|
|
91
|
+
head: str
|
|
92
|
+
items: list[RevisionOut]
|
|
93
|
+
next_cursor: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CountsResponse(BaseModel):
|
|
97
|
+
revision: str
|
|
98
|
+
partitions: list[PartitionCount]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ChangesPage(BaseModel):
|
|
102
|
+
"""A page of element changes with the summary of all of them."""
|
|
103
|
+
|
|
104
|
+
from_revision: str | None = Field(description="Older side; null for a root Revision.")
|
|
105
|
+
to_revision: str
|
|
106
|
+
total: int
|
|
107
|
+
summary: list[PartitionSummary]
|
|
108
|
+
items: list[ChangeOut]
|
|
109
|
+
next_cursor: str | None = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _bounded(limit: int | None, default: int, maximum: int) -> int:
|
|
113
|
+
effective = default if limit is None else limit
|
|
114
|
+
if effective > maximum:
|
|
115
|
+
raise invalid("/query/limit", f"must not exceed {maximum}", "limit_exceeded")
|
|
116
|
+
return effective
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.get(
|
|
120
|
+
f"{BASE}/branches/{{branch:path}}/log",
|
|
121
|
+
summary="History of a branch",
|
|
122
|
+
response_model=LogPage,
|
|
123
|
+
responses={**_ERRORS, **_NOT_MODIFIED},
|
|
124
|
+
)
|
|
125
|
+
async def branch_log(
|
|
126
|
+
domain_id: str,
|
|
127
|
+
branch: str,
|
|
128
|
+
access: DataRead,
|
|
129
|
+
runtime: ReadRuntimeDep,
|
|
130
|
+
session: ReadSessionDep,
|
|
131
|
+
request: Request,
|
|
132
|
+
all_parents: Annotated[
|
|
133
|
+
bool, Query(alias="all", description="Every ancestor instead of the first-parent chain.")
|
|
134
|
+
] = False,
|
|
135
|
+
origin_type: Annotated[str | None, Query(description="Only Revisions of this origin.")] = None,
|
|
136
|
+
author: Annotated[str | None, Query(description="Only Revisions by this principal.")] = None,
|
|
137
|
+
limit: Limit = None,
|
|
138
|
+
cursor: Cursor = None,
|
|
139
|
+
) -> Response:
|
|
140
|
+
"""Revisions of a branch, newest first (first parents by default)."""
|
|
141
|
+
domain = await open_domain(session, access)
|
|
142
|
+
ref = await open_branch(session, access, domain, branch)
|
|
143
|
+
bounds = runtime.read.log
|
|
144
|
+
effective = _bounded(limit, bounds.default_limit, bounds.max_limit)
|
|
145
|
+
shape = fingerprint("log", branch, all_parents, origin_type, author)
|
|
146
|
+
position = position_of(cursor, shape, "/query/cursor")
|
|
147
|
+
after = None
|
|
148
|
+
if position is not None:
|
|
149
|
+
try:
|
|
150
|
+
after = (int(position[0]), bytes.fromhex(position[1]))
|
|
151
|
+
except (IndexError, TypeError, ValueError):
|
|
152
|
+
raise invalid_cursor("/query/cursor") from None
|
|
153
|
+
|
|
154
|
+
async def produce() -> LogPage:
|
|
155
|
+
items, more = await log(
|
|
156
|
+
session,
|
|
157
|
+
domain.id,
|
|
158
|
+
ref.head,
|
|
159
|
+
first_parent=not all_parents,
|
|
160
|
+
origin_type=origin_type,
|
|
161
|
+
author=author,
|
|
162
|
+
limit=effective,
|
|
163
|
+
after=after,
|
|
164
|
+
)
|
|
165
|
+
next_cursor = issue(shape, [items[-1].generation, items[-1].id]) if more and items else None
|
|
166
|
+
return LogPage(branch=ref.name, head=ref.head.hex(), items=items, next_cursor=next_cursor)
|
|
167
|
+
|
|
168
|
+
return await _answer(request, runtime, domain.id, ref.head.hex(), produce)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@router.get(
|
|
172
|
+
f"{BASE}/revisions/{{revision}}",
|
|
173
|
+
summary="Get a Revision",
|
|
174
|
+
response_model=RevisionOut,
|
|
175
|
+
responses={**_ERRORS, **_NOT_MODIFIED},
|
|
176
|
+
)
|
|
177
|
+
async def get_revision(
|
|
178
|
+
domain_id: str,
|
|
179
|
+
revision: str,
|
|
180
|
+
access: DataRead,
|
|
181
|
+
runtime: ReadRuntimeDep,
|
|
182
|
+
session: ReadSessionDep,
|
|
183
|
+
request: Request,
|
|
184
|
+
) -> Response:
|
|
185
|
+
"""A Revision by id, tag or branch name."""
|
|
186
|
+
domain = await open_domain(session, access)
|
|
187
|
+
found = await open_revision(session, access, domain, revision)
|
|
188
|
+
|
|
189
|
+
async def produce() -> RevisionOut:
|
|
190
|
+
return revision_out(found)
|
|
191
|
+
|
|
192
|
+
return await _answer(request, runtime, domain.id, found.id.hex(), produce)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@router.get(
|
|
196
|
+
f"{BASE}/revisions/{{revision}}/counts",
|
|
197
|
+
summary="Element counts per partition at a Revision",
|
|
198
|
+
response_model=CountsResponse,
|
|
199
|
+
responses={**_ERRORS, **_NOT_MODIFIED},
|
|
200
|
+
)
|
|
201
|
+
async def revision_counts(
|
|
202
|
+
domain_id: str,
|
|
203
|
+
revision: str,
|
|
204
|
+
access: DataRead,
|
|
205
|
+
runtime: ReadRuntimeDep,
|
|
206
|
+
session: ReadSessionDep,
|
|
207
|
+
request: Request,
|
|
208
|
+
) -> Response:
|
|
209
|
+
"""Schema elements, Records per Kind and Links per Relation at a Revision."""
|
|
210
|
+
domain = await open_domain(session, access)
|
|
211
|
+
found = await open_revision(session, access, domain, revision)
|
|
212
|
+
|
|
213
|
+
async def produce() -> CountsResponse:
|
|
214
|
+
schema = await schema_at(session, runtime, domain_id, found)
|
|
215
|
+
return CountsResponse(
|
|
216
|
+
revision=found.id.hex(),
|
|
217
|
+
partitions=await partition_counts(session, runtime, domain_id, found, schema),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return await _answer(request, runtime, domain.id, found.id.hex(), produce)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@router.get(
|
|
224
|
+
f"{BASE}/revisions/{{revision}}/changeset",
|
|
225
|
+
summary="Changes of a Revision against its first parent",
|
|
226
|
+
response_model=ChangesPage,
|
|
227
|
+
responses={**_ERRORS, **_NOT_MODIFIED},
|
|
228
|
+
)
|
|
229
|
+
async def revision_changeset(
|
|
230
|
+
domain_id: str,
|
|
231
|
+
revision: str,
|
|
232
|
+
access: DataRead,
|
|
233
|
+
runtime: ReadRuntimeDep,
|
|
234
|
+
session: ReadSessionDep,
|
|
235
|
+
request: Request,
|
|
236
|
+
limit: Limit = None,
|
|
237
|
+
cursor: Cursor = None,
|
|
238
|
+
) -> Response:
|
|
239
|
+
"""The stored changeset (design 6.3), with provenance per changed Attribute."""
|
|
240
|
+
domain = await open_domain(session, access)
|
|
241
|
+
found = await open_revision(session, access, domain, revision)
|
|
242
|
+
parent = await _first_parent(session, domain_id, found)
|
|
243
|
+
new_schema = await schema_at(session, runtime, domain_id, found)
|
|
244
|
+
old_schema = (
|
|
245
|
+
new_schema if parent is None else await schema_at(session, runtime, domain_id, parent)
|
|
246
|
+
)
|
|
247
|
+
masking = (await _masking(session, access, runtime, domain.id)).over(new_schema, old_schema)
|
|
248
|
+
|
|
249
|
+
async def produce() -> ChangesPage:
|
|
250
|
+
entries = await ChangesetRepository(
|
|
251
|
+
session, domain_id, writes=runtime.store_settings.writes
|
|
252
|
+
).entries(found.id)
|
|
253
|
+
changes = [
|
|
254
|
+
HashChange(e.partition, e.key, e.old_hash, e.new_hash, dict(e.provenance) or None)
|
|
255
|
+
for e in entries
|
|
256
|
+
]
|
|
257
|
+
page = await _page(
|
|
258
|
+
session,
|
|
259
|
+
runtime,
|
|
260
|
+
domain_id,
|
|
261
|
+
changes,
|
|
262
|
+
old_schema,
|
|
263
|
+
new_schema,
|
|
264
|
+
masking,
|
|
265
|
+
limit=limit,
|
|
266
|
+
cursor=cursor,
|
|
267
|
+
shape=fingerprint("changeset", found.id.hex()),
|
|
268
|
+
)
|
|
269
|
+
return ChangesPage(
|
|
270
|
+
from_revision=None if parent is None else parent.id.hex(),
|
|
271
|
+
to_revision=found.id.hex(),
|
|
272
|
+
**page,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
disclosure = Disclosure(session, domain.id, (new_schema, old_schema), masking, runtime.vault)
|
|
276
|
+
return await _answer(
|
|
277
|
+
request, runtime, domain.id, found.id.hex(), produce, masking=masking, disclosure=disclosure
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@router.get(
|
|
282
|
+
f"{BASE}/diff",
|
|
283
|
+
summary="Diff two Revisions",
|
|
284
|
+
response_model=ChangesPage,
|
|
285
|
+
responses={**_ERRORS, **_NOT_MODIFIED, 200: {"content": {NDJSON: {}}}},
|
|
286
|
+
)
|
|
287
|
+
async def diff(
|
|
288
|
+
domain_id: str,
|
|
289
|
+
access: DataRead,
|
|
290
|
+
runtime: ReadRuntimeDep,
|
|
291
|
+
session: ReadSessionDep,
|
|
292
|
+
request: Request,
|
|
293
|
+
from_: Annotated[str, Query(alias="from", description="Revision id, tag or branch.")],
|
|
294
|
+
to: Annotated[str, Query(description="Revision id, tag or branch.")],
|
|
295
|
+
kinds: Annotated[
|
|
296
|
+
list[str] | None, Query(description="Kind api names (repeated or comma-separated).")
|
|
297
|
+
] = None,
|
|
298
|
+
relations: Annotated[
|
|
299
|
+
list[str] | None, Query(description="Relation api names (repeated or comma-separated).")
|
|
300
|
+
] = None,
|
|
301
|
+
schema: Annotated[
|
|
302
|
+
bool | None,
|
|
303
|
+
Query(description="Include schema changes; default true unless filters are given."),
|
|
304
|
+
] = None,
|
|
305
|
+
format: Annotated[Literal["json", "ndjson"], Query()] = "json",
|
|
306
|
+
limit: Limit = None,
|
|
307
|
+
cursor: Cursor = None,
|
|
308
|
+
) -> Response:
|
|
309
|
+
"""Element changes from `from` to `to`, with a per-partition summary."""
|
|
310
|
+
domain = await open_domain(session, access)
|
|
311
|
+
old = await open_revision(session, access, domain, from_)
|
|
312
|
+
new = await open_revision(session, access, domain, to)
|
|
313
|
+
old_schema = await schema_at(session, runtime, domain_id, old)
|
|
314
|
+
new_schema = await schema_at(session, runtime, domain_id, new)
|
|
315
|
+
masking = (await _masking(session, access, runtime, domain.id)).over(new_schema, old_schema)
|
|
316
|
+
|
|
317
|
+
async def changes_between() -> tuple[list[HashChange], set[str] | None]:
|
|
318
|
+
partitions = _partitions((new_schema, old_schema), _names(kinds), _names(relations), schema)
|
|
319
|
+
entries = await compute_diff(
|
|
320
|
+
session, runtime, domain_id, old.root, new.root, partitions=partitions
|
|
321
|
+
)
|
|
322
|
+
return [as_hash_change(e) for e in entries], partitions
|
|
323
|
+
|
|
324
|
+
if format == "ndjson":
|
|
325
|
+
changes, _ = await changes_between()
|
|
326
|
+
return StreamingResponse(
|
|
327
|
+
_stream(runtime, domain_id, changes, old_schema, new_schema, masking, old, new),
|
|
328
|
+
media_type=NDJSON,
|
|
329
|
+
headers={REVISION_HEADER: new.id.hex()},
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
async def produce() -> ChangesPage:
|
|
333
|
+
changes, partitions = await changes_between()
|
|
334
|
+
page = await _page(
|
|
335
|
+
session,
|
|
336
|
+
runtime,
|
|
337
|
+
domain_id,
|
|
338
|
+
changes,
|
|
339
|
+
old_schema,
|
|
340
|
+
new_schema,
|
|
341
|
+
masking,
|
|
342
|
+
limit=limit,
|
|
343
|
+
cursor=cursor,
|
|
344
|
+
shape=fingerprint("diff", old.id.hex(), new.id.hex(), sorted(partitions or [])),
|
|
345
|
+
)
|
|
346
|
+
return ChangesPage(from_revision=old.id.hex(), to_revision=new.id.hex(), **page)
|
|
347
|
+
|
|
348
|
+
return await _answer(
|
|
349
|
+
request,
|
|
350
|
+
runtime,
|
|
351
|
+
domain.id,
|
|
352
|
+
f"{old.id.hex()}..{new.id.hex()}",
|
|
353
|
+
produce,
|
|
354
|
+
headers={REVISION_HEADER: new.id.hex()},
|
|
355
|
+
masking=masking,
|
|
356
|
+
disclosure=Disclosure(session, domain.id, (new_schema, old_schema), masking, runtime.vault),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async def _masking(
|
|
361
|
+
session: AsyncSession, access: ReadGuard, runtime: ReadRuntime, domain_id: str
|
|
362
|
+
) -> Masking:
|
|
363
|
+
"""Masking of history reads, which are not tied to one branch: Domain-wide and
|
|
364
|
+
Kind-narrowed clearances count."""
|
|
365
|
+
reference = await default_schema(session, domain_id, runtime.store)
|
|
366
|
+
return await masking_for(
|
|
367
|
+
access.authorizer, access.principal, domain_id, reference, runtime.sensitivity
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
async def _answer(
|
|
372
|
+
request: Request,
|
|
373
|
+
runtime: ReadRuntime,
|
|
374
|
+
domain_id: str,
|
|
375
|
+
revision: str,
|
|
376
|
+
produce: Any,
|
|
377
|
+
*,
|
|
378
|
+
headers: dict[str, str] | None = None,
|
|
379
|
+
masking: Masking | None = None,
|
|
380
|
+
disclosure: Disclosure | None = None,
|
|
381
|
+
) -> Response:
|
|
382
|
+
return await respond(
|
|
383
|
+
request,
|
|
384
|
+
runtime.cache,
|
|
385
|
+
domain_id=domain_id,
|
|
386
|
+
revision=revision,
|
|
387
|
+
produce=produce,
|
|
388
|
+
headers=headers or {REVISION_HEADER: revision},
|
|
389
|
+
variant="" if masking is None else masking.variant,
|
|
390
|
+
disclose=None if disclosure is None else disclosure.send,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _names(values: list[str] | None) -> list[str] | None:
|
|
395
|
+
if values is None:
|
|
396
|
+
return None
|
|
397
|
+
return [name.strip() for value in values for name in value.split(",") if name.strip()]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _partitions(
|
|
401
|
+
schemas: Sequence[Schema],
|
|
402
|
+
kinds: list[str] | None,
|
|
403
|
+
relations: list[str] | None,
|
|
404
|
+
include_schema: bool | None,
|
|
405
|
+
) -> set[str] | None:
|
|
406
|
+
if kinds is None and relations is None:
|
|
407
|
+
return None if include_schema in (None, True) else _all_data(schemas)
|
|
408
|
+
selected: set[str] = {"schema"} if include_schema else set()
|
|
409
|
+
for index, name in enumerate(kinds or []):
|
|
410
|
+
kind = next((s.kind_by_api_name(name) for s in schemas if s.kind_by_api_name(name)), None)
|
|
411
|
+
if kind is None:
|
|
412
|
+
raise invalid(f"/query/kinds/{index}", f"unknown Kind {name!r}", "unknown_kind")
|
|
413
|
+
selected.add(kind_partition(kind.id))
|
|
414
|
+
for index, name in enumerate(relations or []):
|
|
415
|
+
found = next(
|
|
416
|
+
(s.relation_by_api_name(name) for s in schemas if s.relation_by_api_name(name)), None
|
|
417
|
+
)
|
|
418
|
+
if found is None:
|
|
419
|
+
raise invalid(
|
|
420
|
+
f"/query/relations/{index}", f"unknown Relation {name!r}", "unknown_relation"
|
|
421
|
+
)
|
|
422
|
+
selected.add(relation_partition(found[0].id))
|
|
423
|
+
return selected
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _all_data(schemas: Sequence[Schema]) -> set[str]:
|
|
427
|
+
selected: set[str] = set()
|
|
428
|
+
for schema in schemas:
|
|
429
|
+
selected.update(kind_partition(k) for k in schema.kinds)
|
|
430
|
+
selected.update(relation_partition(r) for r in schema.relations)
|
|
431
|
+
return selected
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
async def _first_parent(session: Any, domain_id: str, revision: RevisionRow) -> RevisionRow | None:
|
|
435
|
+
if not revision.parents:
|
|
436
|
+
return None
|
|
437
|
+
return await RevisionRepository(session, domain_id).require(revision.parents[0])
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
async def _page(
|
|
441
|
+
session: Any,
|
|
442
|
+
runtime: ReadRuntime,
|
|
443
|
+
domain_id: str,
|
|
444
|
+
changes: list[HashChange],
|
|
445
|
+
old_schema: Schema,
|
|
446
|
+
new_schema: Schema,
|
|
447
|
+
masking: Masking,
|
|
448
|
+
*,
|
|
449
|
+
limit: int | None,
|
|
450
|
+
cursor: str | None,
|
|
451
|
+
shape: str,
|
|
452
|
+
) -> dict[str, Any]:
|
|
453
|
+
bounds = runtime.read.diff.page
|
|
454
|
+
effective = _bounded(limit, bounds.default_limit, bounds.max_limit)
|
|
455
|
+
position = position_of(cursor, shape, "/query/cursor")
|
|
456
|
+
start = 0
|
|
457
|
+
if position is not None:
|
|
458
|
+
if len(position) != 1 or not isinstance(position[0], int) or position[0] < 0:
|
|
459
|
+
raise invalid_cursor("/query/cursor")
|
|
460
|
+
start = position[0]
|
|
461
|
+
window = changes[start : start + effective]
|
|
462
|
+
items = await expand(
|
|
463
|
+
session,
|
|
464
|
+
runtime,
|
|
465
|
+
domain_id,
|
|
466
|
+
window,
|
|
467
|
+
old_schema=old_schema,
|
|
468
|
+
new_schema=new_schema,
|
|
469
|
+
masking=masking,
|
|
470
|
+
)
|
|
471
|
+
end = start + len(window)
|
|
472
|
+
return {
|
|
473
|
+
"total": len(changes),
|
|
474
|
+
"summary": summarize(changes, (new_schema, old_schema)),
|
|
475
|
+
"items": items,
|
|
476
|
+
"next_cursor": issue(shape, [end]) if end < len(changes) else None,
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
async def _stream(
|
|
481
|
+
runtime: ReadRuntime,
|
|
482
|
+
domain_id: str,
|
|
483
|
+
changes: list[HashChange],
|
|
484
|
+
old_schema: Schema,
|
|
485
|
+
new_schema: Schema,
|
|
486
|
+
masking: Masking,
|
|
487
|
+
old: RevisionRow,
|
|
488
|
+
new: RevisionRow,
|
|
489
|
+
) -> AsyncIterator[bytes]:
|
|
490
|
+
"""JSON lines: one summary line, then one line per change, expanded batch by batch. Each
|
|
491
|
+
batch has its vault references resolved as a JSON answer would (`Disclosure`)."""
|
|
492
|
+
summary = {
|
|
493
|
+
"type": "summary",
|
|
494
|
+
"from_revision": old.id.hex(),
|
|
495
|
+
"to_revision": new.id.hex(),
|
|
496
|
+
"total": len(changes),
|
|
497
|
+
"summary": [
|
|
498
|
+
s.model_dump(mode="json") for s in summarize(changes, (new_schema, old_schema))
|
|
499
|
+
],
|
|
500
|
+
}
|
|
501
|
+
yield (json.dumps(summary) + "\n").encode()
|
|
502
|
+
batch = runtime.read.diff.page.max_limit
|
|
503
|
+
async with runtime.sessions() as session, session.begin():
|
|
504
|
+
await begin_read(session, runtime)
|
|
505
|
+
await scope_to_domain(session, domain_id)
|
|
506
|
+
disclosure = Disclosure(
|
|
507
|
+
session, domain_id, (new_schema, old_schema), masking, runtime.vault
|
|
508
|
+
)
|
|
509
|
+
for start in range(0, len(changes), batch):
|
|
510
|
+
items = await expand(
|
|
511
|
+
session,
|
|
512
|
+
runtime,
|
|
513
|
+
domain_id,
|
|
514
|
+
changes[start : start + batch],
|
|
515
|
+
old_schema=old_schema,
|
|
516
|
+
new_schema=new_schema,
|
|
517
|
+
masking=masking,
|
|
518
|
+
)
|
|
519
|
+
lines = (
|
|
520
|
+
json.dumps({"type": "change", **item.model_dump(mode="json", exclude_none=True)})
|
|
521
|
+
for item in items
|
|
522
|
+
)
|
|
523
|
+
yield await disclosure.send(("\n".join(lines) + "\n").encode(), NDJSON)
|