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,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)