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,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
|
+
]
|