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,601 @@
1
+ """Schema reads and schema change sets (design 5, 6.6).
2
+
3
+ A change set is either a full schema document (YAML, the layout of
4
+ `lorebase_core.model.yaml_schema`) or a patch: a partial document whose elements replace the
5
+ elements of the same api name in the current schema, plus a list of elements to remove. Both
6
+ are turned into the target `Schema` (ids of existing elements are kept: an explicit `id` wins,
7
+ otherwise the element with the same api name keeps its id), validated statically, and diffed
8
+ against the current schema into `SchemaUpsert` and `SchemaDelete` changes that the commit
9
+ pipeline applies as one Revision, data migrations included.
10
+
11
+ A tag rebase (`rebase_tags`) is a schema change computed from the default branch: it gives a
12
+ branch forked before the default branch tagged an Attribute the tags it lacks.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
18
+ from dataclasses import dataclass, field
19
+ from enum import StrEnum
20
+ from types import MappingProxyType
21
+ from typing import Any
22
+
23
+ from sqlalchemy.ext.asyncio import AsyncSession
24
+
25
+ from lorebase_core.expr.sensitivity import rebased_tags, schema_tags
26
+ from lorebase_core.model import (
27
+ Attribute,
28
+ Schema,
29
+ SchemaYamlError,
30
+ Sensitivity,
31
+ load_schema,
32
+ locate_issues,
33
+ schema_from_document,
34
+ schema_to_document,
35
+ validate_schema,
36
+ )
37
+ from lorebase_core.revision import Origin
38
+ from lorebase_core.validate import Issue, SchemaRules
39
+ from lorebase_keeper.auth.principals import Principal
40
+ from lorebase_keeper.context import (
41
+ StoreAccess,
42
+ author_of,
43
+ require_branch,
44
+ require_domain,
45
+ require_revision,
46
+ )
47
+ from lorebase_keeper.reading.sensitivity import (
48
+ Clearance,
49
+ Masking,
50
+ collapse_issues,
51
+ default_schema,
52
+ reference_tags,
53
+ )
54
+ from lorebase_keeper.services.errors import Finding, ProtectedBranchError, findings_of
55
+ from lorebase_keeper.services.reads import ReadTarget
56
+ from lorebase_store.commit import (
57
+ CommitError,
58
+ CommitResult,
59
+ SchemaChange,
60
+ SchemaDelete,
61
+ SchemaElementModel,
62
+ SchemaUpsert,
63
+ ValidationFailedError,
64
+ commit,
65
+ )
66
+ from lorebase_store.commit.schema import schema_elements
67
+ from lorebase_store.materialize.rebuild import load_schema as load_tree_schema
68
+ from lorebase_store.materialize.sensitivity import default_branch_schema
69
+ from lorebase_store.materialize.stale import queue_stale_refresh
70
+ from lorebase_store.nodestore import NodeRepository
71
+ from lorebase_store.repositories import MaterializedRepository, RevisionRow
72
+ from lorebase_store.runtime import StoreRuntime
73
+
74
+ SCHEMA_CHANGE_ORIGIN = "schema_change"
75
+ DEFAULT_MESSAGE = "Schema change"
76
+ REBASE_TAGS_MESSAGE = "Take the sensitivity tags of the default branch"
77
+ DOCUMENT_ISSUE = "schema.document_invalid"
78
+ SECTIONS = ("kinds", "relations", "traits", "operations", "derivations", "sources")
79
+
80
+ SchemaGuard = Callable[[Schema, Schema], Awaitable[None]]
81
+ """Checks a schema change of any branch, `(current, target)`; raises to refuse it."""
82
+
83
+
84
+ class ChangeStatus(StrEnum):
85
+ COMMITTED = "committed"
86
+ DRY_RUN = "dry_run"
87
+ NO_OP = "no_op"
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class ElementRemoval:
92
+ """An element to remove, named by section and api name (or id)."""
93
+
94
+ section: str
95
+ name: str
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class ChangeSetInput:
100
+ """A full document (`yaml`) or a patch (`patch` plus `remove`)."""
101
+
102
+ yaml: str | None = None
103
+ patch: Mapping[str, Any] | None = None
104
+ remove: Sequence[ElementRemoval] = ()
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class ElementChange:
109
+ """One element written or removed by a change set."""
110
+
111
+ op: str
112
+ section: str
113
+ id: str
114
+ api_name: str
115
+
116
+
117
+ @dataclass
118
+ class ChangeSetOutcome:
119
+ """Result of applying or dry-running a change set."""
120
+
121
+ status: ChangeStatus
122
+ head: bytes
123
+ revision: bytes | None
124
+ changes: list[ElementChange]
125
+ data_changes: int = 0
126
+ issues: list[Finding] = field(default_factory=list)
127
+
128
+
129
+ class ChangeSetInvalidError(Exception):
130
+ """The change set cannot be applied; `issues` locate the problems in the document."""
131
+
132
+ def __init__(self, issues: Sequence[Finding]) -> None:
133
+ super().__init__(f"{len(issues)} issue(s)")
134
+ self.issues = list(issues)
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class TagChange:
139
+ """Sensitivity tags a tag rebase adds to one Attribute of a Kind or Relation."""
140
+
141
+ section: str
142
+ owner: str
143
+ attribute: str
144
+ tags: list[Sensitivity]
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class TagRebase:
149
+ """Result of `rebase_tags`: the schema change and the tags it adds."""
150
+
151
+ outcome: ChangeSetOutcome
152
+ tags: list[TagChange]
153
+ default_branch: str
154
+ """Where the tags come from: who may not read its schema is not told them."""
155
+
156
+
157
+ class RemovalBlockedError(Exception):
158
+ """Removing elements would delete data and `cascade` was not requested."""
159
+
160
+ def __init__(self, issues: Sequence[Finding]) -> None:
161
+ super().__init__(f"{len(issues)} element(s) hold data")
162
+ self.issues = list(issues)
163
+
164
+
165
+ # Pure parts ---------------------------------------------------------------------------------
166
+
167
+
168
+ def _section_of(element: SchemaElementModel) -> str:
169
+ return {
170
+ "schema.domain": "domain",
171
+ "schema.kind": "kinds",
172
+ "schema.relation": "relations",
173
+ "schema.trait": "traits",
174
+ "schema.operation": "operations",
175
+ "schema.derivation": "derivations",
176
+ "schema.source": "sources",
177
+ }[element.object_type]
178
+
179
+
180
+ def _api_name(element: SchemaElementModel) -> str:
181
+ return str(getattr(element, "api_name", None) or element.id)
182
+
183
+
184
+ def diff_schemas(current: Schema, target: Schema) -> tuple[list[SchemaChange], list[ElementChange]]:
185
+ """Schema changes turning `current` into `target`, with their description."""
186
+ before = schema_elements(current)
187
+ after = schema_elements(target)
188
+ changes: list[SchemaChange] = []
189
+ described: list[ElementChange] = []
190
+ for key, element in after.items():
191
+ old = before.get(key)
192
+ if old is None or old.content() != element.content():
193
+ changes.append(SchemaUpsert(element))
194
+ op = "create" if old is None else "update"
195
+ described.append(ElementChange(op, _section_of(element), key, _api_name(element)))
196
+ for key, element in before.items():
197
+ if key not in after:
198
+ changes.append(SchemaDelete(key))
199
+ described.append(ElementChange("delete", _section_of(element), key, _api_name(element)))
200
+ return changes, described
201
+
202
+
203
+ def _document_findings(error: SchemaYamlError) -> list[Finding]:
204
+ return [Finding(DOCUMENT_ISSUE, p.message, tuple(p.path)) for p in error.problems]
205
+
206
+
207
+ def _patched_document(current: Schema, change: ChangeSetInput) -> dict[str, Any]:
208
+ document = schema_to_document(current)
209
+ for section, items in (change.patch or {}).items():
210
+ if section == "domain" and isinstance(items, Mapping):
211
+ document["domain"] = {**document.get("domain", {}), **items}
212
+ elif section in SECTIONS and isinstance(items, Mapping):
213
+ document[section] = {**document.get(section, {}), **items}
214
+ else:
215
+ document[section] = items # unknown sections are reported by the loader
216
+ for removal in change.remove:
217
+ elements = document.get(removal.section)
218
+ if not isinstance(elements, dict):
219
+ raise ChangeSetInvalidError([_issue((removal.section,), "unknown section")])
220
+ name = removal.name
221
+ if name not in elements:
222
+ name = next(
223
+ (
224
+ n
225
+ for n, body in elements.items()
226
+ if isinstance(body, Mapping) and body.get("id") == removal.name
227
+ ),
228
+ name,
229
+ )
230
+ if name not in elements:
231
+ raise ChangeSetInvalidError(
232
+ [_issue((removal.section, removal.name), "no such element to remove")]
233
+ )
234
+ del elements[name]
235
+ return document
236
+
237
+
238
+ def _issue(path: tuple[str | int, ...], message: str) -> Finding:
239
+ return Finding(DOCUMENT_ISSUE, message, path)
240
+
241
+
242
+ def target_schema(current: Schema, change: ChangeSetInput) -> Schema:
243
+ """The schema a change set asks for; raises `ChangeSetInvalidError` when it cannot be read."""
244
+ try:
245
+ if change.yaml is not None:
246
+ target = load_schema(change.yaml, base=current)
247
+ else:
248
+ target = schema_from_document(_patched_document(current, change), base=current)
249
+ except SchemaYamlError as error:
250
+ raise ChangeSetInvalidError(_document_findings(error)) from None
251
+ if target.domain.id != current.domain.id:
252
+ raise ChangeSetInvalidError([_issue(("domain", "id"), f"must be {current.domain.id!r}")])
253
+ return target
254
+
255
+
256
+ def static_issues(target: Schema, rules: SchemaRules) -> list[Finding]:
257
+ """Errors of static validation, located in the document (api names, YAML paths).
258
+
259
+ `rules` come from the `schema` settings (`StoreRuntime.schema`), the same the commit
260
+ pipeline applies."""
261
+ return located(target, [i for i in validate_schema(target, rules) if i.is_error])
262
+
263
+
264
+ def located(target: Schema, issues: Sequence[Issue]) -> list[Finding]:
265
+ """Findings of core issues with ids replaced by api names."""
266
+ return findings_of(locate_issues(target, issues))
267
+
268
+
269
+ # Store-backed parts -------------------------------------------------------------------------
270
+
271
+
272
+ async def schema_at(
273
+ session: AsyncSession, domain_id: str, revision: RevisionRow, runtime: StoreRuntime
274
+ ) -> Schema:
275
+ """The schema stored in the tree of a Revision."""
276
+ settings = runtime.settings
277
+ nodes = NodeRepository(
278
+ session, domain_id, writes=settings.writes, batch=settings.nodes.prefetch_batch
279
+ )
280
+ tree = await nodes.load_tree(revision.root)
281
+ schema, _ = await load_tree_schema(session, domain_id, tree, runtime=runtime)
282
+ return schema
283
+
284
+
285
+ async def read(store: StoreAccess, domain_id: str, target: ReadTarget) -> Schema:
286
+ """The schema at a Revision the caller was authorized to read (`reads.resolve_readable`)."""
287
+ async with store.sessions() as session, session.begin():
288
+ await require_domain(session, domain_id)
289
+ revision = await require_revision(session, domain_id, target.revision)
290
+ return await schema_at(session, domain_id, revision, store.runtime)
291
+
292
+
293
+ async def validate(
294
+ store: StoreAccess,
295
+ domain_id: str,
296
+ change: ChangeSetInput,
297
+ revision: ReadTarget,
298
+ ) -> tuple[list[Finding], list[ElementChange]]:
299
+ """Static issues of a change set against the schema at an authorized Revision, and the
300
+ element changes it would make. Nothing is written."""
301
+ current = await read(store, domain_id, revision)
302
+ try:
303
+ target = target_schema(current, change)
304
+ except ChangeSetInvalidError as invalid:
305
+ return invalid.issues, []
306
+ _, described = diff_schemas(current, target)
307
+ return static_issues(target, store.runtime.schema.rules()), described
308
+
309
+
310
+ async def apply(
311
+ store: StoreAccess,
312
+ domain_id: str,
313
+ branch: str,
314
+ change: ChangeSetInput,
315
+ principal: Principal,
316
+ *,
317
+ message: str | None,
318
+ expected_head: bytes | None,
319
+ dry_run: bool,
320
+ cascade: bool,
321
+ guard: SchemaGuard | None = None,
322
+ cleared: frozenset[Sensitivity] = frozenset(),
323
+ clearance: Clearance | None = None,
324
+ ) -> ChangeSetOutcome:
325
+ """Apply a change set to a branch as one Revision, or run it without committing.
326
+
327
+ `guard(current, target)` may refuse the change early (it raises), as for sensitivity tags
328
+ removed without the clearance (`reading.sensitivity`); the commit pipeline refuses, under
329
+ the branch lock, removing any tag outside `cleared` (the caller's Domain-wide clearance).
330
+ Data issues about values `clearance` does not cover (none, when it is not given) are
331
+ folded into one generic issue per Attribute, with a count and no Record key
332
+ (`collapse_issues`): revalidating stored values with a new rule must not tell a caller
333
+ what values it may not read look like, nor which Records hold them.
334
+
335
+ Raises `ChangeSetInvalidError` (document, static or data issues), `RemovalBlockedError`,
336
+ `ProtectedBranchError` for direct writes to a protected branch, and the other
337
+ `CommitError`s of the pipeline. A dry run reports data issues in the outcome instead.
338
+ """
339
+ async with store.sessions() as session:
340
+ await session.begin()
341
+ try:
342
+ await require_domain(session, domain_id)
343
+ ref = await require_branch(session, domain_id, branch)
344
+ if ref.protected and not dry_run:
345
+ raise ProtectedBranchError(branch)
346
+ head_revision = await require_revision(session, domain_id, ref.head)
347
+ current = await schema_at(session, domain_id, head_revision, store.runtime)
348
+ target = target_schema(current, change)
349
+ issues = static_issues(target, store.runtime.schema.rules())
350
+ if issues:
351
+ raise ChangeSetInvalidError(issues)
352
+ if guard is not None:
353
+ await guard(current, target)
354
+ changes, described = diff_schemas(current, target)
355
+ if not cascade:
356
+ await _check_removals(session, domain_id, branch, current, changes, store)
357
+ return await _commit_target(
358
+ session,
359
+ store,
360
+ domain_id,
361
+ branch,
362
+ principal,
363
+ ref.head,
364
+ target,
365
+ changes,
366
+ described,
367
+ message=message or DEFAULT_MESSAGE,
368
+ expected_head=expected_head,
369
+ dry_run=dry_run,
370
+ cleared=cleared,
371
+ clearance=clearance,
372
+ )
373
+ finally:
374
+ if session.in_transaction():
375
+ await session.rollback()
376
+
377
+
378
+ async def rebase_tags(
379
+ store: StoreAccess,
380
+ domain_id: str,
381
+ branch: str,
382
+ principal: Principal,
383
+ *,
384
+ message: str | None,
385
+ expected_head: bytes | None,
386
+ dry_run: bool,
387
+ ) -> TagRebase:
388
+ """Commit to a branch the sensitivity tags of the default branch's schema (design 10.2).
389
+
390
+ The branch's schema gets, on every Attribute both schemas have, the tags the default
391
+ branch gives it, and on its derived Attributes the tags of what their Derivations read
392
+ (`lorebase_core.expr.sensitivity.rebased_tags`); no tag is removed. The commit recomputes
393
+ the titles and search vectors of the Kinds it changes, in its transaction, so none the
394
+ branch materialized before the tags stays stored. On the default branch, or a branch
395
+ that already has the tags, nothing changes (`no_op`).
396
+
397
+ Raises `ProtectedBranchError` when there is something to commit to a protected branch
398
+ (not for a dry run), `ChangeSetInvalidError` when the branch's schema stays invalid with
399
+ the tags, and the `CommitError`s of the pipeline.
400
+ """
401
+ async with store.sessions() as session:
402
+ await session.begin()
403
+ try:
404
+ domain = await require_domain(session, domain_id)
405
+ ref = await require_branch(session, domain_id, branch)
406
+ head_revision = await require_revision(session, domain_id, ref.head)
407
+ current = await schema_at(session, domain_id, head_revision, store.runtime)
408
+ reference = await default_branch_schema(session, domain_id, runtime=store.runtime)
409
+ target = rebased_tags(
410
+ current, schema_tags(reference), store.runtime.schema.expression_limits
411
+ )
412
+ changes, described = diff_schemas(current, target)
413
+ added = _added_tags(current, target)
414
+ if not changes:
415
+ return TagRebase(
416
+ ChangeSetOutcome(ChangeStatus.NO_OP, ref.head, ref.head, []),
417
+ added,
418
+ domain.default_branch,
419
+ )
420
+ if ref.protected and not dry_run:
421
+ raise ProtectedBranchError(branch)
422
+ outcome = await _commit_target(
423
+ session,
424
+ store,
425
+ domain_id,
426
+ branch,
427
+ principal,
428
+ ref.head,
429
+ target,
430
+ changes,
431
+ described,
432
+ message=message or REBASE_TAGS_MESSAGE,
433
+ # The target was computed from this head: a commit on top of another refuses.
434
+ expected_head=expected_head or ref.head,
435
+ dry_run=dry_run,
436
+ )
437
+ return TagRebase(outcome, added, domain.default_branch)
438
+ finally:
439
+ if session.in_transaction():
440
+ await session.rollback()
441
+
442
+
443
+ async def queue_tag_refresh(
444
+ store: StoreAccess, domain_id: str, branch: str, *, dry_run: bool
445
+ ) -> tuple[bytes, int | None]:
446
+ """Queue the batched rewrite of the titles and search vectors a branch materialized
447
+ before the default branch tagged an Attribute, without changing its schema
448
+ (`lorebase_store.materialize.stale`). Returns the branch head and the job id (None for
449
+ a dry run, which only checks the branch)."""
450
+ async with store.sessions() as session, session.begin():
451
+ await require_domain(session, domain_id)
452
+ ref = await require_branch(session, domain_id, branch)
453
+ if dry_run:
454
+ return ref.head, None
455
+ return ref.head, await queue_stale_refresh(session, domain_id, branch)
456
+
457
+
458
+ async def _commit_target(
459
+ session: AsyncSession,
460
+ store: StoreAccess,
461
+ domain_id: str,
462
+ branch: str,
463
+ principal: Principal,
464
+ head: bytes,
465
+ target: Schema,
466
+ changes: Sequence[SchemaChange],
467
+ described: list[ElementChange],
468
+ *,
469
+ message: str,
470
+ expected_head: bytes | None,
471
+ dry_run: bool,
472
+ cleared: frozenset[Sensitivity] = frozenset(),
473
+ clearance: Clearance | None = None,
474
+ ) -> ChangeSetOutcome:
475
+ """Commit `changes` (turning the branch's schema into `target`) in the open transaction
476
+ of `session`, and commit the transaction unless `dry_run`."""
477
+ outcome = ChangeSetOutcome(
478
+ status=ChangeStatus.DRY_RUN if dry_run else ChangeStatus.COMMITTED,
479
+ head=head,
480
+ revision=None,
481
+ changes=described,
482
+ )
483
+ try:
484
+ result = await commit(
485
+ session,
486
+ domain_id,
487
+ branch,
488
+ [],
489
+ schema_changes=changes,
490
+ author=author_of(principal),
491
+ message=message,
492
+ origin=Origin(type=SCHEMA_CHANGE_ORIGIN),
493
+ expected_head=expected_head,
494
+ runtime=store.runtime,
495
+ clock=store.clock,
496
+ allowed_lowering=cleared,
497
+ )
498
+ except ValidationFailedError as error:
499
+ reference = await default_schema(session, domain_id, store.runtime)
500
+ masking = Masking(
501
+ clearance or Clearance(),
502
+ reference=MappingProxyType(reference_tags(reference)),
503
+ ).over(target)
504
+ issues = located(target, collapse_issues(error.issues, (target,), masking))
505
+ if not dry_run:
506
+ raise ChangeSetInvalidError(issues) from error
507
+ outcome.issues = issues
508
+ return outcome
509
+ _fill(outcome, result)
510
+ if dry_run:
511
+ outcome.revision = None
512
+ return outcome
513
+ await session.commit()
514
+ return outcome
515
+
516
+
517
+ def _added_tags(current: Schema, target: Schema) -> list[TagChange]:
518
+ """The tags `target` adds to Attributes of `current`, by owner and Attribute api name."""
519
+ owners: list[tuple[str, str, Mapping[str, Attribute], Mapping[str, Attribute]]] = [
520
+ ("kinds", kind.api_name, kind.attributes, target.kinds[kind.id].attributes)
521
+ for kind in current.kinds.values()
522
+ if kind.id in target.kinds
523
+ ]
524
+ owners += [
525
+ ("relations", rel.api_name, rel.attributes, target.relations[rel.id].attributes)
526
+ for rel in current.relations.values()
527
+ if rel.id in target.relations
528
+ ]
529
+ added: list[TagChange] = []
530
+ for section, owner, before, after in owners:
531
+ for attribute_id, attribute in before.items():
532
+ new = after.get(attribute_id)
533
+ gained = set() if new is None else set(new.sensitivity) - set(attribute.sensitivity)
534
+ if gained:
535
+ added.append(TagChange(section, owner, attribute.api_name, sorted(gained)))
536
+ return added
537
+
538
+
539
+ def _fill(outcome: ChangeSetOutcome, result: CommitResult) -> None:
540
+ if result.no_op:
541
+ outcome.status = ChangeStatus.NO_OP
542
+ outcome.revision = result.revision_id
543
+ return
544
+ outcome.revision = result.revision_id
545
+ outcome.data_changes = sum(1 for e in result.changeset if e.partition != "schema")
546
+
547
+
548
+ async def _check_removals(
549
+ session: AsyncSession,
550
+ domain_id: str,
551
+ branch: str,
552
+ current: Schema,
553
+ changes: Sequence[SchemaChange],
554
+ store: StoreAccess,
555
+ ) -> None:
556
+ removed = {c.element_id for c in changes if isinstance(c, SchemaDelete)}
557
+ if not removed:
558
+ return
559
+ rows = MaterializedRepository(session, domain_id, branch, writes=store.runtime.settings.writes)
560
+ records = await rows.record_counts()
561
+ links = await rows.link_counts()
562
+ blocked: list[Finding] = []
563
+ for element_id in sorted(removed):
564
+ kind = current.kinds.get(element_id)
565
+ relation = current.relations.get(element_id)
566
+ count = records.get(element_id, 0) if kind else links.get(element_id, 0)
567
+ if (kind or relation) and count:
568
+ name = kind.api_name if kind else relation.api_name # type: ignore[union-attr]
569
+ section = "kinds" if kind else "relations"
570
+ what = "Records" if kind else "Links"
571
+ blocked.append(
572
+ _issue(
573
+ (section, name),
574
+ f"{name!r} holds {count} {what}; removing it deletes them. Pass cascade: "
575
+ "true to confirm.",
576
+ )
577
+ )
578
+ if blocked:
579
+ raise RemovalBlockedError(blocked)
580
+
581
+
582
+ __all__ = [
583
+ "ChangeSetInput",
584
+ "ChangeSetInvalidError",
585
+ "ChangeSetOutcome",
586
+ "ChangeStatus",
587
+ "CommitError",
588
+ "ElementChange",
589
+ "ElementRemoval",
590
+ "RemovalBlockedError",
591
+ "TagChange",
592
+ "TagRebase",
593
+ "apply",
594
+ "diff_schemas",
595
+ "queue_tag_refresh",
596
+ "read",
597
+ "rebase_tags",
598
+ "schema_at",
599
+ "target_schema",
600
+ "validate",
601
+ ]