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,279 @@
1
+ """Preview of a candidate Derivation on sample Records (design 5.8), without writing.
2
+
3
+ The candidate is a schema change set (`services.schema.ChangeSetInput`) naming the Derivation
4
+ to evaluate: the schema it produces is validated with the rules of schema changes
5
+ (`schema.expression_limits` included), then the Derivation is evaluated on a few Records of its
6
+ Kind at the branch head, from the stored and derived values of the branch, with the evaluator
7
+ the materializer uses (`lorebase_store.materialize.derived`). Nothing is stored.
8
+
9
+ What the caller may see follows Record reads: the Kinds the Derivation touches (its own, the
10
+ far Kind of an aggregate, the Kinds reached through `links.<relation>`) must be readable, and
11
+ the value is masked when the computed Attribute is sensitive in the schema read, in the default
12
+ branch's or in the candidate, which carries the tags of everything the Derivation may read
13
+ (`lorebase_core.expr.sensitivity`), unless the caller is cleared for each tag on the
14
+ Derivation's Kind and on every Kind the tag comes from (`tag_origins`: a value computed from a
15
+ sensitive Attribute of a far Kind tells about its Records). A masked value only says whether
16
+ it failed: no error code, no message.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Mapping, Sequence
22
+ from dataclasses import dataclass
23
+
24
+ from pydantic import JsonValue
25
+ from sqlalchemy.ext.asyncio import AsyncSession
26
+
27
+ from lorebase_core.expr import (
28
+ CompiledDerivation,
29
+ ExprLimits,
30
+ compile_derivation,
31
+ derivation_reads,
32
+ links_read,
33
+ )
34
+ from lorebase_core.expr.sensitivity import schema_tags, tag_origins
35
+ from lorebase_core.expr.visibility import element_ids
36
+ from lorebase_core.model import (
37
+ AggregateDerivation,
38
+ Attribute,
39
+ Derivation,
40
+ ExpressionDerivation,
41
+ Kind,
42
+ LinkDirection,
43
+ Schema,
44
+ Sensitivity,
45
+ )
46
+ from lorebase_core.validate import SchemaRules
47
+ from lorebase_keeper.auth.read_audit import note_sensitive
48
+ from lorebase_keeper.reading.points import ReadPoint
49
+ from lorebase_keeper.reading.runtime import ReadRuntime
50
+ from lorebase_keeper.services.errors import Finding
51
+ from lorebase_keeper.services.schema import (
52
+ ChangeSetInput,
53
+ ChangeSetInvalidError,
54
+ static_issues,
55
+ target_schema,
56
+ )
57
+ from lorebase_store.materialize.derived import DerivedValues
58
+ from lorebase_store.repositories import MaterializedRepository
59
+
60
+ DERIVATION_NOT_FOUND = "derivation.not_found"
61
+ DERIVATION_UNSUPPORTED = "derivation.unsupported"
62
+ DERIVATION_ID_UNAVAILABLE = "derivation.id_unavailable"
63
+
64
+
65
+ class CandidateInvalidError(Exception):
66
+ """The candidate cannot be evaluated; `issues` locate why."""
67
+
68
+ def __init__(self, issues: Sequence[Finding]) -> None:
69
+ super().__init__(f"{len(issues)} issue(s)")
70
+ self.issues = list(issues)
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Candidate:
75
+ """A validated candidate: the schema the change set asks for and the Derivation in it."""
76
+
77
+ schema: Schema
78
+ derivation: Derivation
79
+ kind: Kind
80
+ attribute: Attribute
81
+ compiled: CompiledDerivation
82
+ limits: ExprLimits
83
+
84
+ @property
85
+ def touched(self) -> frozenset[str]:
86
+ """Ids of the Kinds the Derivation reads Records of, its own included."""
87
+ return touched_kinds(self.schema, self.derivation, self.compiled, self.limits)
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class PreviewItem:
92
+ """One sample Record: its key and title, and the value or the error masked for the caller.
93
+
94
+ A masked item carries the masked value (none with `keeper.reads.sensitivity.omit`) and
95
+ whether the value failed (`failed`), without the error code or message.
96
+ """
97
+
98
+ key: str
99
+ title: str | None
100
+ value: JsonValue
101
+ masked: bool
102
+ failed: bool = False
103
+ error_code: str | None = None
104
+ error_message: str | None = None
105
+
106
+
107
+ def _issue(code: str, message: str, path: tuple[str | int, ...]) -> Finding:
108
+ return Finding(code, message, path)
109
+
110
+
111
+ def candidate_of(
112
+ current: Schema, change: ChangeSetInput, name: str, rules: SchemaRules
113
+ ) -> Candidate:
114
+ """The Derivation `name` of the schema `change` asks for, once the schema is valid.
115
+
116
+ Raises `CandidateInvalidError` when the change set cannot be read, fails static validation
117
+ (the issues of a schema change) or `name` is not an `expression` or `aggregate` Derivation
118
+ of it. Path Derivations compose Links and compute no values.
119
+ """
120
+ try:
121
+ schema = target_schema(current, change)
122
+ except ChangeSetInvalidError as invalid:
123
+ raise CandidateInvalidError(invalid.issues) from None
124
+ issues = static_issues(schema, rules)
125
+ if issues:
126
+ raise CandidateInvalidError(issues)
127
+ derivation = schema.derivation_by_api_name(name)
128
+ if derivation is None:
129
+ raise CandidateInvalidError(
130
+ [
131
+ _issue(
132
+ DERIVATION_NOT_FOUND, f"No Derivation {name!r} in the schema.", ("derivations",)
133
+ )
134
+ ]
135
+ )
136
+ definition = derivation.definition
137
+ if not isinstance(definition, ExpressionDerivation | AggregateDerivation):
138
+ raise CandidateInvalidError(
139
+ [
140
+ _issue(
141
+ DERIVATION_UNSUPPORTED,
142
+ "Only expression and aggregate Derivations compute values; a path "
143
+ "Derivation composes Links.",
144
+ ("derivations", name, "definition", "variant"),
145
+ )
146
+ ]
147
+ )
148
+ kind = schema.kinds[definition.kind]
149
+ attribute = kind.attributes[definition.attribute]
150
+ limits = rules.expression_limits or ExprLimits()
151
+ compiled = compile_derivation(schema, derivation, limits)
152
+ return Candidate(schema, derivation, kind, attribute, compiled, limits)
153
+
154
+
155
+ def require_new_ids(candidate: Candidate, hidden: frozenset[str]) -> None:
156
+ """Refuse a candidate built on a projected schema (`ReadPoint.hidden`) that reuses an id
157
+ the projection left out: Records and values stored under it are not the caller's to read
158
+ (design 13.3)."""
159
+ reused = sorted(element_ids(candidate.schema) & hidden)
160
+ if reused:
161
+ raise CandidateInvalidError(
162
+ [_issue(DERIVATION_ID_UNAVAILABLE, f"Id {reused[0]} cannot be used here.", ())]
163
+ )
164
+
165
+
166
+ def touched_kinds(
167
+ schema: Schema,
168
+ derivation: Derivation,
169
+ compiled: CompiledDerivation,
170
+ limits: ExprLimits | None = None,
171
+ ) -> frozenset[str]:
172
+ """Ids of the Kinds whose Records the Derivation reads: its own, the Kinds of the
173
+ Attributes it may read and the far Kinds of the Relations it follows (every Kind a path
174
+ Derivation crosses)."""
175
+ definition = derivation.definition
176
+ if not isinstance(definition, ExpressionDerivation | AggregateDerivation):
177
+ return frozenset()
178
+ paths = schema.path_relations()
179
+ kinds = {definition.kind}
180
+ kinds.update(kind_id for kind_id, _ in derivation_reads(schema, derivation, limits))
181
+ followed: list[tuple[str, LinkDirection]] = []
182
+ if isinstance(definition, AggregateDerivation):
183
+ followed.append((definition.relation, definition.direction))
184
+ for program in (compiled.expression, compiled.filter):
185
+ if program is not None:
186
+ followed.extend((f.relation.id, f.direction) for f in links_read(program))
187
+ for relation_id, direction in followed:
188
+ relation = schema.link_relation(relation_id)
189
+ if relation is not None:
190
+ kinds.add(relation.far_endpoint(direction))
191
+ if relation_id in paths:
192
+ kinds.update(paths[relation_id].kinds)
193
+ return frozenset(kinds)
194
+
195
+
196
+ def kind_names(schema: Schema, kind_ids: frozenset[str]) -> list[str]:
197
+ """Api names of Kinds, sorted."""
198
+ return sorted(schema.kinds[k].api_name for k in kind_ids if k in schema.kinds)
199
+
200
+
201
+ async def preview(
202
+ session: AsyncSession,
203
+ runtime: ReadRuntime,
204
+ point: ReadPoint,
205
+ candidate: Candidate,
206
+ *,
207
+ keys: Sequence[str] | None,
208
+ limit: int,
209
+ ) -> list[PreviewItem]:
210
+ """Evaluate the candidate on `keys` (the first `limit` Records of its Kind by key when
211
+ `None`), at the branch head `point` names, in the read transaction of `session`.
212
+
213
+ The caller has checked the Kinds the candidate touches against the point's scope.
214
+ """
215
+ store = runtime.store
216
+ repository = MaterializedRepository(
217
+ session,
218
+ point.domain.id,
219
+ point.branch,
220
+ writes=store.settings.writes,
221
+ layout=point.layout,
222
+ )
223
+ materialize = store.settings.materialize
224
+ values = DerivedValues(
225
+ repository,
226
+ candidate.schema,
227
+ limits=store.schema.expression_limits,
228
+ now=point.revision.created_at,
229
+ batch=materialize.read_batch_rows,
230
+ max_incremental=materialize.derivation_max_incremental_records,
231
+ fanout=store.settings.traverse.path_fanout,
232
+ )
233
+ masking = point.masking.over(point.schema, candidate.schema)
234
+ origins = tag_origins(
235
+ candidate.schema,
236
+ candidate.attribute.id,
237
+ _united(masking.reference, schema_tags(point.schema)),
238
+ candidate.limits,
239
+ )
240
+ masked = masking.hidden_with_origins(candidate.attribute, (candidate.kind.id,), origins)
241
+ items: list[PreviewItem] = []
242
+ for found in await values.preview(candidate.attribute.id, keys=keys, limit=limit):
243
+ title = masking.title(candidate.schema, candidate.kind.id, found.title)
244
+ if masked:
245
+ note_sensitive("masked")
246
+ items.append(
247
+ PreviewItem(
248
+ found.key,
249
+ title,
250
+ None if masking.settings.omit else masking.masked,
251
+ True,
252
+ found.error is not None,
253
+ )
254
+ )
255
+ continue
256
+ if masking.sensitive(candidate.attribute):
257
+ note_sensitive("shown")
258
+ error = found.error
259
+ items.append(
260
+ PreviewItem(
261
+ found.key,
262
+ title,
263
+ found.value,
264
+ False,
265
+ error is not None,
266
+ None if error is None else error.code.value,
267
+ None if error is None else error.describe(),
268
+ )
269
+ )
270
+ return items
271
+
272
+
273
+ def _united(*tag_maps: Mapping[str, frozenset[Sensitivity]]) -> dict[str, frozenset[Sensitivity]]:
274
+ """Attribute id -> tags, united over `tag_maps`."""
275
+ united: dict[str, frozenset[Sensitivity]] = {}
276
+ for tags in tag_maps:
277
+ for attribute_id, found in tags.items():
278
+ united[attribute_id] = united.get(attribute_id, frozenset()) | found
279
+ return united
@@ -0,0 +1,114 @@
1
+ """Document intake by URL: the guarded fetch behind `POST /domains/{d}/documents` with a URL.
2
+
3
+ The URL needs a scheme in `keeper.documents.url_schemes` and a host in `url_hosts` (exact
4
+ match). The host is then resolved once and every address must be global unicast, the check of
5
+ webhook targets and HTTP JSON Sources (`lorebase_store.webhooks.delivery.check_target`), unless
6
+ `url_allow_private_targets`. The request connects to those checked addresses only, never to a
7
+ new lookup, so a DNS answer that changes after the check (rebinding) cannot reach an internal
8
+ service; the `Host` header and the TLS server name keep the host name. Redirects are never
9
+ followed, proxies from the environment are ignored and the body is read in bounded steps.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from lorebase_keeper.services.config import DocumentSettings
19
+ from lorebase_store.webhooks import WebhookSettings
20
+ from lorebase_store.webhooks.delivery import Resolver, check_target, resolve_host
21
+
22
+
23
+ class UrlRefusedError(ValueError):
24
+ """The URL may not be fetched (scheme, host or the addresses it resolves to)."""
25
+
26
+
27
+ class UrlUnavailableError(RuntimeError):
28
+ """The URL could not be fetched now (resolution, connection or an answer other than 200)."""
29
+
30
+
31
+ class DocumentTooLargeError(ValueError):
32
+ """The body exceeds `keeper.documents.max_bytes`."""
33
+
34
+
35
+ def pin(url: httpx.URL, address: str) -> tuple[httpx.URL, dict[str, str], dict[str, Any]]:
36
+ """`url` rewritten to connect to the checked `address`, with the `Host` header and request
37
+ extensions that keep the host name for the server and for TLS (as Scribe's webhook sender
38
+ and Source connectors do)."""
39
+ return (
40
+ url.copy_with(host=address.split("%", 1)[0]),
41
+ {"Host": url.netloc.decode("ascii")},
42
+ {"sni_hostname": url.host} if url.scheme == "https" else {},
43
+ )
44
+
45
+
46
+ async def checked_addresses(
47
+ url: httpx.URL, settings: DocumentSettings, resolve: Resolver
48
+ ) -> tuple[str, ...]:
49
+ """Addresses a fetch of `url` may connect to; empty when private targets are allowed (the
50
+ client then resolves the host itself)."""
51
+ if url.scheme not in settings.url_schemes:
52
+ raise UrlRefusedError(f"scheme must be one of {', '.join(settings.url_schemes)}")
53
+ allowed = {host.lower() for host in settings.url_hosts}
54
+ if not url.host or url.host.lower() not in allowed:
55
+ raise UrlRefusedError("the host is not in keeper.documents.url_hosts")
56
+ checks = WebhookSettings(
57
+ allowed_schemes=settings.url_schemes,
58
+ allow_private_targets=settings.url_allow_private_targets,
59
+ )
60
+ target = await check_target(str(url), checks, resolve)
61
+ if target.problem is None:
62
+ return target.addresses
63
+ if target.problem.startswith("cannot resolve"):
64
+ raise UrlUnavailableError("the URL host cannot be resolved")
65
+ # `check_target` problems never contain the host name or its addresses.
66
+ raise UrlRefusedError(
67
+ f"the URL host {target.problem}, refused unless keeper.documents.url_allow_private_targets"
68
+ )
69
+
70
+
71
+ async def fetch(
72
+ url: str,
73
+ settings: DocumentSettings,
74
+ *,
75
+ resolve: Resolver = resolve_host,
76
+ transport: httpx.AsyncBaseTransport | None = None,
77
+ ) -> bytes:
78
+ """The body of a 200 answer to GET `url`, at most `settings.max_bytes` long."""
79
+ target = httpx.URL(url)
80
+ addresses = await checked_addresses(target, settings, resolve)
81
+ attempts = [pin(target, address) for address in addresses] or [(target, {}, {})]
82
+ async with httpx.AsyncClient(
83
+ follow_redirects=False,
84
+ timeout=settings.url_timeout_s,
85
+ trust_env=False,
86
+ transport=transport,
87
+ ) as client:
88
+ failure: httpx.HTTPError | None = None
89
+ for pinned, headers, extensions in attempts:
90
+ try:
91
+ return await _get(client, pinned, headers, extensions, settings.max_bytes)
92
+ except (httpx.ConnectError, httpx.ConnectTimeout) as error:
93
+ failure = error # the next checked address, never a new lookup
94
+ except httpx.HTTPError as error:
95
+ raise UrlUnavailableError(f"the URL could not be fetched: {error}") from None
96
+ raise UrlUnavailableError(f"the URL could not be fetched: {failure}")
97
+
98
+
99
+ async def _get(
100
+ client: httpx.AsyncClient,
101
+ url: httpx.URL,
102
+ headers: dict[str, str],
103
+ extensions: dict[str, Any],
104
+ limit: int,
105
+ ) -> bytes:
106
+ data = bytearray()
107
+ async with client.stream("GET", url, headers=headers, extensions=extensions) as response:
108
+ if response.status_code != 200:
109
+ raise UrlUnavailableError(f"the URL answered {response.status_code}")
110
+ async for block in response.aiter_bytes():
111
+ data.extend(block)
112
+ if len(data) > limit:
113
+ raise DocumentTooLargeError(f"the document exceeds {limit} bytes")
114
+ return bytes(data)
@@ -0,0 +1,118 @@
1
+ """Domain use cases: create (root Revision and default branch), read, update, delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from sqlalchemy.exc import IntegrityError
8
+
9
+ from lorebase_core.model import Domain
10
+ from lorebase_keeper.auth.principals import Principal
11
+ from lorebase_keeper.context import StoreAccess, author_of, require_domain
12
+ from lorebase_keeper.errors import NOT_FOUND, ProblemError
13
+ from lorebase_keeper.services.config import DomainSettings
14
+ from lorebase_keeper.services.errors import ALREADY_EXISTS
15
+ from lorebase_store.auth import GrantRepository
16
+ from lorebase_store.commit import create_domain
17
+ from lorebase_store.repositories import DomainRepository, DomainRow, RefRepository
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CreatedDomain:
22
+ """A Domain and the head of its default branch; `replayed` when it already existed."""
23
+
24
+ domain: DomainRow
25
+ head: bytes
26
+ replayed: bool = False
27
+
28
+
29
+ async def create(
30
+ store: StoreAccess,
31
+ domain: Domain,
32
+ principal: Principal,
33
+ settings: DomainSettings,
34
+ *,
35
+ idempotency_key: str | None,
36
+ ) -> CreatedDomain:
37
+ """Create a Domain: row, root Revision, protected default branch and the creator's grant.
38
+
39
+ With an idempotency key, creating a Domain that the same principal already created
40
+ returns it (`replayed`); otherwise an existing id is a conflict.
41
+ """
42
+ async with store.sessions() as session:
43
+ existing = await DomainRepository(session).get(domain.id)
44
+ if existing is not None:
45
+ return await _existing(store, existing, principal, idempotency_key)
46
+ try:
47
+ async with store.sessions() as session, session.begin():
48
+ result = await create_domain(
49
+ session,
50
+ domain,
51
+ author=author_of(principal),
52
+ leaf_size=settings.leaf_size,
53
+ runtime=store.runtime,
54
+ clock=store.clock,
55
+ )
56
+ if not settings.protect_default_branch:
57
+ await RefRepository(session, domain.id).set_protected(domain.default_branch, False)
58
+ if settings.creator_role and principal.method != "none":
59
+ await GrantRepository(session).add(
60
+ domain_id=domain.id,
61
+ subject=principal.id,
62
+ role=settings.creator_role,
63
+ created_by=principal.id,
64
+ )
65
+ row = await DomainRepository(session).require(domain.id)
66
+ except IntegrityError:
67
+ async with store.sessions() as session:
68
+ found = await DomainRepository(session).get(domain.id)
69
+ if found is None:
70
+ raise
71
+ return await _existing(store, found, principal, idempotency_key)
72
+ return CreatedDomain(domain=row, head=result.revision_id)
73
+
74
+
75
+ async def _existing(
76
+ store: StoreAccess, row: DomainRow, principal: Principal, idempotency_key: str | None
77
+ ) -> CreatedDomain:
78
+ if idempotency_key is None or row.created_by != principal.id:
79
+ raise ProblemError(ALREADY_EXISTS, f"Domain {row.id!r} already exists.")
80
+ async with store.sessions() as session, session.begin():
81
+ await require_domain(session, row.id)
82
+ ref = await RefRepository(session, row.id).require(row.default_branch)
83
+ return CreatedDomain(domain=row, head=ref.head, replayed=True)
84
+
85
+
86
+ async def get(store: StoreAccess, domain_id: str) -> tuple[DomainRow, bytes | None]:
87
+ """The Domain and the head of its default branch."""
88
+ async with store.sessions() as session, session.begin():
89
+ domain = await require_domain(session, domain_id)
90
+ ref = await RefRepository(session, domain_id).get(domain.default_branch)
91
+ return domain, None if ref is None else ref.head
92
+
93
+
94
+ async def page(store: StoreAccess, *, limit: int, after: str | None) -> list[DomainRow]:
95
+ """Domains ordered by id."""
96
+ async with store.sessions() as session:
97
+ return await DomainRepository(session).list(limit=limit, after=after)
98
+
99
+
100
+ async def update(
101
+ store: StoreAccess, domain_id: str, *, display_name: str | None, description: str | None
102
+ ) -> DomainRow:
103
+ """Change display metadata (not versioned: the Domain element of the schema is unchanged)."""
104
+ async with store.sessions() as session, session.begin():
105
+ await require_domain(session, domain_id)
106
+ row = await DomainRepository(session).update(
107
+ domain_id, display_name=display_name, description=description
108
+ )
109
+ if row is None: # pragma: no cover - deleted concurrently
110
+ raise ProblemError(NOT_FOUND, f"Domain {domain_id!r} not found.")
111
+ return row
112
+
113
+
114
+ async def delete(store: StoreAccess, domain_id: str) -> None:
115
+ """Delete a Domain and everything scoped to it. The audit log keeps its entries."""
116
+ async with store.sessions() as session, session.begin():
117
+ await require_domain(session, domain_id)
118
+ await DomainRepository(session).delete(domain_id)